diff --git a/overseerr-api.yml b/overseerr-api.yml index 796c6bda7..3ff713f92 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -289,6 +289,15 @@ components: isDefault: type: boolean example: false + externalUrl: + type: string + example: http://radarr.example.com + syncEnabled: + type: boolean + example: false + preventSearch: + type: boolean + example: false required: - name - hostname @@ -352,6 +361,15 @@ components: isDefault: type: boolean example: false + externalUrl: + type: string + example: http://radarr.example.com + syncEnabled: + type: boolean + example: false + preventSearch: + type: boolean + example: false required: - name - hostname @@ -1918,12 +1936,91 @@ paths: items: type: object properties: + id: + type: string + example: job-name name: type: string example: A Job Name + type: + type: string + enum: [process, command] nextExecutionTime: type: string example: '2020-09-02T05:02:23.000Z' + running: + type: boolean + example: false + /settings/jobs/{jobId}/run: + get: + summary: Invoke a specific job + description: Invokes a specific job to run. Will return the new job status in JSON format. + tags: + - settings + parameters: + - in: path + name: jobId + required: true + schema: + type: string + responses: + '200': + description: Invoked job returned + content: + application/json: + schema: + type: object + properties: + id: + type: string + example: job-name + type: + type: string + enum: [process, command] + name: + type: string + example: A Job Name + nextExecutionTime: + type: string + example: '2020-09-02T05:02:23.000Z' + running: + type: boolean + example: false + /settings/jobs/{jobId}/cancel: + get: + summary: Cancel a specific job + description: Cancels a specific job. Will return the new job status in JSON format. + tags: + - settings + parameters: + - in: path + name: jobId + required: true + schema: + type: string + responses: + '200': + description: Cancelled job returned + content: + application/json: + schema: + type: object + properties: + id: + type: string + example: job-name + type: + type: string + enum: [process, command] + name: + type: string + example: A Job Name + nextExecutionTime: + type: string + example: '2020-09-02T05:02:23.000Z' + running: + type: boolean + example: false /settings/notifications: get: summary: Return notification settings diff --git a/server/api/radarr.ts b/server/api/radarr.ts index 53d8c7ede..9c70b9e58 100644 --- a/server/api/radarr.ts +++ b/server/api/radarr.ts @@ -1,4 +1,5 @@ import Axios, { AxiosInstance } from 'axios'; +import { RadarrSettings } from '../lib/settings'; import logger from '../logger'; interface RadarrMovieOptions { @@ -13,12 +14,13 @@ interface RadarrMovieOptions { searchNow?: boolean; } -interface RadarrMovie { +export interface RadarrMovie { id: number; title: string; isAvailable: boolean; monitored: boolean; tmdbId: number; + imdbId: string; titleSlug: string; folderName: string; path: string; @@ -45,7 +47,39 @@ export interface RadarrProfile { 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 { + static buildRadarrUrl(radarrSettings: RadarrSettings, path?: string): string { + return `${radarrSettings.useSsl ? 'https' : 'http'}://${ + radarrSettings.hostname + }:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}${path}`; + } + private axios: AxiosInstance; constructor({ url, apiKey }: { url: string; apiKey: string }) { this.axios = Axios.create({ @@ -76,8 +110,43 @@ class RadarrAPI { } }; - public addMovie = async (options: RadarrMovieOptions): Promise => { + public async getMovieByTmdbId(id: number): Promise { try { + const response = await this.axios.get('/movie/lookup', { + params: { + term: `tmdb:${id}`, + }, + }); + + if (!response.data[0]) { + throw new Error('Movie not found'); + } + + return response.data[0]; + } catch (e) { + logger.error('Error retrieving movie by TMDb ID', { + label: 'Radarr API', + message: e.message, + }); + throw new Error('Movie not found'); + } + } + + public addMovie = async ( + options: RadarrMovieOptions + ): Promise => { + try { + // Check if movie already exists + const existing = await this.getMovieByTmdbId(options.tmdbId); + + if (existing) { + logger.info( + 'Movie already exists in Radarr. Skipping add and returning success', + { label: 'Radarr' } + ); + return existing; + } + const response = await this.axios.post(`/movie`, { title: options.title, qualityProfileId: options.qualityProfileId, @@ -104,9 +173,9 @@ class RadarrAPI { label: 'Radarr', options, }); - return false; + throw new Error('Failed to add movie to Radarr'); } - return true; + return response.data; } catch (e) { logger.error( 'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.', @@ -117,10 +186,7 @@ class RadarrAPI { response: e?.response?.data, } ); - if (e?.response?.data?.[0]?.errorCode === 'MovieExistsValidator') { - return true; - } - return false; + throw new Error('Failed to add movie to Radarr'); } }; @@ -143,6 +209,16 @@ class RadarrAPI { 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/sonarr.ts index 109191f37..29c6b431f 100644 --- a/server/api/sonarr.ts +++ b/server/api/sonarr.ts @@ -1,9 +1,18 @@ import Axios, { AxiosInstance } from 'axios'; +import { SonarrSettings } from '../lib/settings'; import logger from '../logger'; interface SonarrSeason { seasonNumber: number; monitored: boolean; + statistics?: { + previousAiring?: string; + episodeFileCount: number; + episodeCount: number; + totalEpisodeCount: number; + sizeOnDisk: number; + percentOfEpisodes: number; + }; } export interface SonarrSeries { @@ -55,6 +64,33 @@ 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; @@ -84,6 +120,12 @@ interface AddSeriesOptions { } class SonarrAPI { + static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string { + return `${sonarrSettings.useSsl ? 'https' : 'http'}://${ + sonarrSettings.hostname + }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}${path}`; + } + private axios: AxiosInstance; constructor({ url, apiKey }: { url: string; apiKey: string }) { this.axios = Axios.create({ @@ -94,6 +136,16 @@ class SonarrAPI { }); } + public async getSeries(): Promise { + try { + const response = await this.axios.get('/series'); + + return response.data; + } catch (e) { + throw new Error(`[Radarr] Failed to retrieve series: ${e.message}`); + } + } + public async getSeriesByTitle(title: string): Promise { try { const response = await this.axios.get('/series/lookup', { @@ -138,7 +190,7 @@ class SonarrAPI { } } - public async addSeries(options: AddSeriesOptions): Promise { + public async addSeries(options: AddSeriesOptions): Promise { try { const series = await this.getSeriesByTvdbId(options.tvdbid); @@ -160,19 +212,19 @@ class SonarrAPI { logger.info('Sonarr accepted request. Updated existing series', { label: 'Sonarr', }); - logger.debug('Sonarr add details', { + logger.debug('Sonarr update details', { label: 'Sonarr', movie: newSeriesResponse.data, }); } else { - logger.error('Failed to add movie to Sonarr', { + logger.error('Failed to update series in Sonarr', { label: 'Sonarr', options, }); - return false; + throw new Error('Failed to update series in Sonarr'); } - return true; + return newSeriesResponse.data; } const createdSeriesResponse = await this.axios.post( @@ -211,10 +263,10 @@ class SonarrAPI { label: 'Sonarr', options, }); - return false; + throw new Error('Failed to add series to Sonarr'); } - return true; + return createdSeriesResponse.data; } catch (e) { logger.error('Something went wrong while adding a series to Sonarr.', { label: 'Sonarr API', @@ -222,7 +274,7 @@ class SonarrAPI { error: e, response: e?.response?.data, }); - return false; + throw new Error('Failed to add series'); } } @@ -282,6 +334,16 @@ class SonarrAPI { 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 dc269f5aa..19786e2b1 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -8,11 +8,16 @@ import { UpdateDateColumn, getRepository, In, + AfterLoad, } from 'typeorm'; import { MediaRequest } from './MediaRequest'; import { MediaStatus, MediaType } from '../constants/media'; import logger from '../logger'; 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 { @@ -104,9 +109,150 @@ class Media { @Column({ type: 'datetime', nullable: true }) public mediaAddedAt: Date; + @Column({ nullable: true }) + public serviceId?: number; + + @Column({ nullable: true }) + public serviceId4k?: number; + + @Column({ nullable: true }) + public externalServiceId?: number; + + @Column({ nullable: true }) + public externalServiceId4k?: number; + + @Column({ nullable: true }) + public externalServiceSlug?: string; + + @Column({ nullable: true }) + public externalServiceSlug4k?: string; + + public serviceUrl?: string; + public serviceUrl4k?: string; + public downloadStatus?: DownloadingItem[] = []; + public downloadStatus4k?: DownloadingItem[] = []; + constructor(init?: Partial) { Object.assign(this, init); } + + @AfterLoad() + public setServiceUrl(): void { + if (this.mediaType === MediaType.MOVIE) { + if (this.serviceId !== null) { + const settings = getSettings(); + const server = settings.radarr.find( + (radarr) => radarr.id === this.serviceId + ); + + if (server) { + this.serviceUrl = server.externalUrl + ? `${server.externalUrl}/movie/${this.externalServiceSlug}` + : RadarrAPI.buildRadarrUrl( + server, + `/movie/${this.externalServiceSlug}` + ); + } + } + + if (this.serviceId4k !== null) { + const settings = getSettings(); + const server = settings.radarr.find( + (radarr) => radarr.id === this.serviceId4k + ); + + if (server) { + this.serviceUrl4k = server.externalUrl + ? `${server.externalUrl}/movie/${this.externalServiceSlug4k}` + : RadarrAPI.buildRadarrUrl( + server, + `/movie/${this.externalServiceSlug4k}` + ); + } + } + } + + if (this.mediaType === MediaType.TV) { + if (this.serviceId !== null) { + const settings = getSettings(); + const server = settings.sonarr.find( + (sonarr) => sonarr.id === this.serviceId + ); + + if (server) { + this.serviceUrl = server.externalUrl + ? `${server.externalUrl}/series/${this.externalServiceSlug}` + : SonarrAPI.buildSonarrUrl( + server, + `/series/${this.externalServiceSlug}` + ); + } + } + + if (this.serviceId4k !== null) { + const settings = getSettings(); + const server = settings.sonarr.find( + (sonarr) => sonarr.id === this.serviceId4k + ); + + if (server) { + this.serviceUrl4k = server.externalUrl + ? `${server.externalUrl}/series/${this.externalServiceSlug4k}` + : SonarrAPI.buildSonarrUrl( + server, + `/series/${this.externalServiceSlug4k}` + ); + } + } + } + } + + @AfterLoad() + public getDownloadingItem(): void { + if (this.mediaType === MediaType.MOVIE) { + if ( + this.externalServiceId !== undefined && + this.serviceId !== undefined + ) { + this.downloadStatus = downloadTracker.getMovieProgress( + this.serviceId, + this.externalServiceId + ); + } + + if ( + this.externalServiceId4k !== undefined && + this.serviceId4k !== undefined + ) { + this.downloadStatus4k = downloadTracker.getMovieProgress( + this.serviceId4k, + this.externalServiceId4k + ); + } + } + + if (this.mediaType === MediaType.TV) { + if ( + this.externalServiceId !== undefined && + this.serviceId !== undefined + ) { + this.downloadStatus = downloadTracker.getSeriesProgress( + this.serviceId, + this.externalServiceId + ); + } + + if ( + this.externalServiceId4k !== undefined && + this.serviceId4k !== undefined + ) { + this.downloadStatus4k = downloadTracker.getSeriesProgress( + this.serviceId4k, + this.externalServiceId4k + ); + } + } + } } export default Media; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index cd5e2ce33..1ba74961b 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -411,31 +411,37 @@ export class MediaRequest { tmdbId: movie.id, year: Number(movie.release_date.slice(0, 4)), monitored: true, - searchNow: true, + searchNow: !radarrSettings.preventSearch, }) - .then(async (success) => { - if (!success) { - media.status = MediaStatus.UNKNOWN; - await mediaRepository.save(media); - logger.warn( - 'Newly added movie request failed to add to Radarr, marking as unknown', - { - label: 'Media Request', - } - ); - const userRepository = getRepository(User); - const admin = await userRepository.findOneOrFail({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, - }); - notificationManager.sendNotification(Notification.MEDIA_FAILED, { - subject: movie.title, - message: 'Movie failed to add to Radarr', - notifyUser: admin, - media, - image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, - }); - } + .then(async (radarrMovie) => { + media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] = + radarrMovie.id; + media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = + radarrMovie.titleSlug; + media[this.is4k ? 'serviceId4k' : 'serviceId'] = radarrSettings?.id; + await mediaRepository.save(media); + }) + .catch(async () => { + media.status = MediaStatus.UNKNOWN; + await mediaRepository.save(media); + logger.warn( + 'Newly added movie request failed to add to Radarr, marking as unknown', + { + label: 'Media Request', + } + ); + const userRepository = getRepository(User); + const admin = await userRepository.findOneOrFail({ + select: ['id', 'plexToken'], + order: { id: 'ASC' }, + }); + notificationManager.sendNotification(Notification.MEDIA_FAILED, { + subject: movie.title, + message: 'Movie failed to add to Radarr', + notifyUser: admin, + media, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, + }); }); logger.info('Sent request to Radarr', { label: 'Media Request' }); } catch (e) { @@ -572,38 +578,54 @@ export class MediaRequest { seasonFolder: sonarrSettings.enableSeasonFolders, seriesType, monitored: true, - searchNow: true, + searchNow: !sonarrSettings.preventSearch, }) - .then(async (success) => { - if (!success) { - media.status = MediaStatus.UNKNOWN; - await mediaRepository.save(media); - logger.warn( - 'Newly added series request failed to add to Sonarr, marking as unknown', - { - label: 'Media Request', - } - ); - const userRepository = getRepository(User); - const admin = await userRepository.findOneOrFail({ - order: { id: 'ASC' }, - }); - notificationManager.sendNotification(Notification.MEDIA_FAILED, { - subject: series.name, - message: 'Series failed to add to Sonarr', - notifyUser: admin, - image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`, - media, - extra: [ - { - name: 'Seasons', - value: this.seasons - .map((season) => season.seasonNumber) - .join(', '), - }, - ], - }); + .then(async (sonarrSeries) => { + // We grab media again here to make sure we have the latest version of it + const media = await mediaRepository.findOne({ + where: { id: this.media.id }, + relations: ['requests'], + }); + + if (!media) { + throw new Error('Media data is missing'); } + + media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] = + sonarrSeries.id; + media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = + sonarrSeries.titleSlug; + media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id; + await mediaRepository.save(media); + }) + .catch(async () => { + media.status = MediaStatus.UNKNOWN; + await mediaRepository.save(media); + logger.warn( + 'Newly added series request failed to add to Sonarr, marking as unknown', + { + label: 'Media Request', + } + ); + const userRepository = getRepository(User); + const admin = await userRepository.findOneOrFail({ + order: { id: 'ASC' }, + }); + notificationManager.sendNotification(Notification.MEDIA_FAILED, { + subject: series.name, + message: 'Series failed to add to Sonarr', + notifyUser: admin, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`, + media, + extra: [ + { + name: 'Seasons', + value: this.seasons + .map((season) => season.seasonNumber) + .join(', '), + }, + ], + }); }); logger.info('Sent request to Sonarr', { label: 'Media Request' }); } catch (e) { diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts index 0a744750f..bc315e351 100644 --- a/server/job/plexsync/index.ts +++ b/server/job/plexsync/index.ts @@ -442,7 +442,11 @@ class JobPlexSync { ); // Total episodes that are in standard definition (not 4k) const totalStandard = episodes.filter((episode) => - episode.Media.some((media) => media.videoResolution !== '4k') + !this.enable4kShow + ? true + : episode.Media.some( + (media) => media.videoResolution !== '4k' + ) ).length; // Total episodes that are in 4k @@ -461,9 +465,9 @@ class JobPlexSync { ? MediaStatus.PARTIALLY_AVAILABLE : existingSeason.status; existingSeason.status4k = - total4k === season.episode_count + this.enable4kShow && total4k === season.episode_count ? MediaStatus.AVAILABLE - : total4k > 0 + : this.enable4kShow && total4k > 0 ? MediaStatus.PARTIALLY_AVAILABLE : existingSeason.status4k; } else { @@ -479,9 +483,9 @@ class JobPlexSync { ? MediaStatus.PARTIALLY_AVAILABLE : MediaStatus.UNKNOWN, status4k: - total4k === season.episode_count + this.enable4kShow && total4k === season.episode_count ? MediaStatus.AVAILABLE - : total4k > 0 + : this.enable4kShow && total4k > 0 ? MediaStatus.PARTIALLY_AVAILABLE : MediaStatus.UNKNOWN, }) @@ -563,13 +567,15 @@ class JobPlexSync { ) ? MediaStatus.PARTIALLY_AVAILABLE : MediaStatus.UNKNOWN; - media.status4k = isAll4kSeasons - ? MediaStatus.AVAILABLE - : media.seasons.some( - (season) => season.status4k !== MediaStatus.UNKNOWN - ) - ? MediaStatus.PARTIALLY_AVAILABLE - : MediaStatus.UNKNOWN; + media.status4k = + isAll4kSeasons && this.enable4kShow + ? MediaStatus.AVAILABLE + : this.enable4kShow && + media.seasons.some( + (season) => season.status4k !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN; await mediaRepository.save(media); this.log(`Updating existing title: ${tvShow.name}`); } else { @@ -586,13 +592,15 @@ class JobPlexSync { ) ? MediaStatus.PARTIALLY_AVAILABLE : MediaStatus.UNKNOWN, - status4k: isAll4kSeasons - ? MediaStatus.AVAILABLE - : newSeasons.some( - (season) => season.status4k !== MediaStatus.UNKNOWN - ) - ? MediaStatus.PARTIALLY_AVAILABLE - : MediaStatus.UNKNOWN, + status4k: + isAll4kSeasons && this.enable4kShow + ? MediaStatus.AVAILABLE + : this.enable4kShow && + newSeasons.some( + (season) => season.status4k !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN, }); await mediaRepository.save(newMedia); this.log(`Saved ${tvShow.name}`); @@ -772,7 +780,8 @@ class JobPlexSync { this.log( this.isRecentOnly ? 'Recently Added Scan Complete' - : 'Full Scan Complete' + : 'Full Scan Complete', + 'info' ); } catch (e) { logger.error('Sync interrupted', { diff --git a/server/job/radarrsync/index.ts b/server/job/radarrsync/index.ts new file mode 100644 index 000000000..57f88ee05 --- /dev/null +++ b/server/job/radarrsync/index.ts @@ -0,0 +1,248 @@ +import { uniqWith } from 'lodash'; +import { getRepository } from 'typeorm'; +import { v4 as uuid } from 'uuid'; +import RadarrAPI, { RadarrMovie } from '../../api/radarr'; +import { MediaStatus, MediaType } from '../../constants/media'; +import Media from '../../entity/Media'; +import { getSettings, RadarrSettings } from '../../lib/settings'; +import logger from '../../logger'; + +const BUNDLE_SIZE = 50; +const UPDATE_RATE = 4 * 1000; + +interface SyncStatus { + running: boolean; + progress: number; + total: number; + currentServer: RadarrSettings; + servers: RadarrSettings[]; +} + +class JobRadarrSync { + private running = false; + private progress = 0; + private enable4k = false; + private sessionId: string; + private servers: RadarrSettings[]; + private currentServer: RadarrSettings; + private radarrApi: RadarrAPI; + private items: RadarrMovie[] = []; + + public async run() { + const settings = getSettings(); + const sessionId = uuid(); + this.sessionId = sessionId; + this.log('Radarr sync starting', 'info', { sessionId }); + + try { + this.running = true; + + // Remove any duplicate Radarr servers and assign them to the servers field + this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => { + return ( + radarrA.hostname === radarrB.hostname && + radarrA.port === radarrB.port && + radarrA.baseUrl === radarrB.baseUrl + ); + }); + + this.enable4k = settings.radarr.some((radarr) => radarr.is4k); + if (this.enable4k) { + this.log( + 'At least one 4K Radarr server was detected. 4K movie detection is now enabled.', + 'info' + ); + } + + for (const server of this.servers) { + this.currentServer = server; + if (server.syncEnabled) { + this.log( + `Beginning to process Radarr server: ${server.name}`, + 'info' + ); + + this.radarrApi = new RadarrAPI({ + apiKey: server.apiKey, + url: RadarrAPI.buildRadarrUrl(server, '/api/v3'), + }); + + this.items = await this.radarrApi.getMovies(); + + await this.loop({ sessionId }); + } else { + this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`); + } + } + + this.log('Radarr sync complete', 'info'); + } catch (e) { + this.log('Something went wrong.', 'error', { errorMessage: e.message }); + } finally { + // If a new scanning session hasnt started, set running back to false + if (this.sessionId === sessionId) { + this.running = false; + } + } + } + + public status(): SyncStatus { + return { + running: this.running, + progress: this.progress, + total: this.items.length, + currentServer: this.currentServer, + servers: this.servers, + }; + } + + public cancel(): void { + this.running = false; + } + + private async processRadarrMovie(radarrMovie: RadarrMovie) { + const mediaRepository = getRepository(Media); + const server4k = this.enable4k && this.currentServer.is4k; + + const media = await mediaRepository.findOne({ + where: { tmdbId: radarrMovie.tmdbId }, + }); + + if (media) { + let isChanged = false; + if (media.status === MediaStatus.AVAILABLE) { + this.log(`Movie already available: ${radarrMovie.title}`); + } else { + media[server4k ? 'status4k' : 'status'] = radarrMovie.downloaded + ? MediaStatus.AVAILABLE + : MediaStatus.PROCESSING; + this.log( + `Updated existing ${server4k ? '4K ' : ''}movie ${ + radarrMovie.title + } to status ${MediaStatus[media[server4k ? 'status4k' : 'status']]}` + ); + isChanged = true; + } + + if ( + media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id + ) { + media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id; + this.log(`Updated service ID for media entity: ${radarrMovie.title}`); + isChanged = true; + } + + if ( + media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !== + radarrMovie.id + ) { + media[server4k ? 'externalServiceId4k' : 'externalServiceId'] = + radarrMovie.id; + this.log( + `Updated external service ID for media entity: ${radarrMovie.title}` + ); + isChanged = true; + } + + if ( + media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !== + radarrMovie.titleSlug + ) { + media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = + radarrMovie.titleSlug; + this.log( + `Updated external service slug for media entity: ${radarrMovie.title}` + ); + isChanged = true; + } + + if (isChanged) { + await mediaRepository.save(media); + } + } else { + const newMedia = new Media({ + tmdbId: radarrMovie.tmdbId, + imdbId: radarrMovie.imdbId, + mediaType: MediaType.MOVIE, + serviceId: !server4k ? this.currentServer.id : undefined, + serviceId4k: server4k ? this.currentServer.id : undefined, + externalServiceId: !server4k ? radarrMovie.id : undefined, + externalServiceId4k: server4k ? radarrMovie.id : undefined, + status: + !server4k && radarrMovie.downloaded + ? MediaStatus.AVAILABLE + : !server4k + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN, + status4k: + server4k && radarrMovie.downloaded + ? MediaStatus.AVAILABLE + : server4k + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN, + }); + + this.log( + `Added media for movie ${radarrMovie.title} and set status to ${ + MediaStatus[newMedia[server4k ? 'status4k' : 'status']] + }` + ); + await mediaRepository.save(newMedia); + } + } + + private async processItems(items: RadarrMovie[]) { + await Promise.all( + items.map(async (radarrMovie) => { + await this.processRadarrMovie(radarrMovie); + }) + ); + } + + private async loop({ + start = 0, + end = BUNDLE_SIZE, + sessionId, + }: { + start?: number; + end?: number; + sessionId?: string; + } = {}) { + const slicedItems = this.items.slice(start, end); + + if (!this.running) { + throw new Error('Sync was aborted.'); + } + + if (this.sessionId !== sessionId) { + throw new Error('New session was started. Old session aborted.'); + } + + if (start < this.items.length) { + this.progress = start; + await this.processItems(slicedItems); + + await new Promise((resolve, reject) => + setTimeout(() => { + this.loop({ + start: start + BUNDLE_SIZE, + end: end + BUNDLE_SIZE, + sessionId, + }) + .then(() => resolve()) + .catch((e) => reject(new Error(e.message))); + }, UPDATE_RATE) + ); + } + } + + private log( + message: string, + level: 'info' | 'error' | 'debug' | 'warn' = 'debug', + optional?: Record + ): void { + logger[level](message, { label: 'Radarr Sync', ...optional }); + } +} + +export const jobRadarrSync = new JobRadarrSync(); diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 82945a466..342f54a16 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,10 +1,17 @@ import schedule from 'node-schedule'; import { jobPlexFullSync, jobPlexRecentSync } from './plexsync'; import logger from '../logger'; +import { jobRadarrSync } from './radarrsync'; +import { jobSonarrSync } from './sonarrsync'; +import downloadTracker from '../lib/downloadtracker'; interface ScheduledJob { + id: string; job: schedule.Job; name: string; + type: 'process' | 'command'; + running?: () => boolean; + cancelFn?: () => void; } export const scheduledJobs: ScheduledJob[] = []; @@ -12,21 +19,80 @@ export const scheduledJobs: ScheduledJob[] = []; export const startJobs = (): void => { // Run recently added plex sync every 5 minutes scheduledJobs.push({ + id: 'plex-recently-added-sync', name: 'Plex Recently Added Sync', + type: 'process', job: schedule.scheduleJob('0 */5 * * * *', () => { logger.info('Starting scheduled job: Plex Recently Added Sync', { label: 'Jobs', }); jobPlexRecentSync.run(); }), + running: () => jobPlexRecentSync.status().running, + cancelFn: () => jobPlexRecentSync.cancel(), }); + // Run full plex sync every 24 hours scheduledJobs.push({ + id: 'plex-full-sync', name: 'Plex Full Library Sync', + type: 'process', job: schedule.scheduleJob('0 0 3 * * *', () => { logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' }); jobPlexFullSync.run(); }), + running: () => jobPlexFullSync.status().running, + cancelFn: () => jobPlexFullSync.cancel(), + }); + + // Run full radarr sync every 24 hours + scheduledJobs.push({ + id: 'radarr-sync', + name: 'Radarr Sync', + type: 'process', + job: schedule.scheduleJob('0 0 4 * * *', () => { + logger.info('Starting scheduled job: Radarr Sync', { label: 'Jobs' }); + jobRadarrSync.run(); + }), + running: () => jobRadarrSync.status().running, + cancelFn: () => jobRadarrSync.cancel(), + }); + + // Run full sonarr sync every 24 hours + scheduledJobs.push({ + id: 'sonarr-sync', + name: 'Sonarr Sync', + type: 'process', + job: schedule.scheduleJob('0 30 4 * * *', () => { + logger.info('Starting scheduled job: Sonarr Sync', { label: 'Jobs' }); + jobSonarrSync.run(); + }), + running: () => jobSonarrSync.status().running, + cancelFn: () => jobSonarrSync.cancel(), + }); + + // Run download sync + scheduledJobs.push({ + id: 'download-sync', + name: 'Download Sync', + type: 'command', + job: schedule.scheduleJob('0 * * * * *', () => { + logger.debug('Starting scheduled job: Download Sync', { label: 'Jobs' }); + downloadTracker.updateDownloads(); + }), + }); + + // Reset download sync + scheduledJobs.push({ + id: 'download-sync-reset', + name: 'Download Sync Reset', + type: 'command', + job: schedule.scheduleJob('0 0 1 * * *', () => { + logger.info('Starting scheduled job: Download Sync Reset', { + label: 'Jobs', + }); + downloadTracker.resetDownloadTracker(); + }), }); logger.info('Scheduled jobs loaded', { label: 'Jobs' }); diff --git a/server/job/sonarrsync/index.ts b/server/job/sonarrsync/index.ts new file mode 100644 index 000000000..b1f72f85e --- /dev/null +++ b/server/job/sonarrsync/index.ts @@ -0,0 +1,358 @@ +import { uniqWith } from 'lodash'; +import { getRepository } from 'typeorm'; +import { v4 as uuid } from 'uuid'; +import SonarrAPI, { SonarrSeries } from '../../api/sonarr'; +import TheMovieDb, { TmdbTvDetails } from '../../api/themoviedb'; +import { MediaStatus, MediaType } from '../../constants/media'; +import Media from '../../entity/Media'; +import Season from '../../entity/Season'; +import { getSettings, SonarrSettings } from '../../lib/settings'; +import logger from '../../logger'; + +const BUNDLE_SIZE = 50; +const UPDATE_RATE = 4 * 1000; + +interface SyncStatus { + running: boolean; + progress: number; + total: number; + currentServer: SonarrSettings; + servers: SonarrSettings[]; +} + +class JobSonarrSync { + private running = false; + private progress = 0; + private enable4k = false; + private sessionId: string; + private servers: SonarrSettings[]; + private currentServer: SonarrSettings; + private sonarrApi: SonarrAPI; + private items: SonarrSeries[] = []; + + public async run() { + const settings = getSettings(); + const sessionId = uuid(); + this.sessionId = sessionId; + this.log('Sonarr sync starting', 'info', { sessionId }); + + try { + this.running = true; + + // Remove any duplicate Sonarr servers and assign them to the servers field + this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => { + return ( + sonarrA.hostname === sonarrB.hostname && + sonarrA.port === sonarrB.port && + sonarrA.baseUrl === sonarrB.baseUrl + ); + }); + + this.enable4k = settings.sonarr.some((sonarr) => sonarr.is4k); + if (this.enable4k) { + this.log( + 'At least one 4K Sonarr server was detected. 4K movie detection is now enabled.', + 'info' + ); + } + + for (const server of this.servers) { + this.currentServer = server; + if (server.syncEnabled) { + this.log( + `Beginning to process Sonarr server: ${server.name}`, + 'info' + ); + + this.sonarrApi = new SonarrAPI({ + apiKey: server.apiKey, + url: SonarrAPI.buildSonarrUrl(server, '/api/v3'), + }); + + this.items = await this.sonarrApi.getSeries(); + + await this.loop({ sessionId }); + } else { + this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`); + } + } + + this.log('Sonarr sync complete', 'info'); + } catch (e) { + this.log('Something went wrong.', 'error', { errorMessage: e.message }); + } finally { + // If a new scanning session hasnt started, set running back to false + if (this.sessionId === sessionId) { + this.running = false; + } + } + } + + public status(): SyncStatus { + return { + running: this.running, + progress: this.progress, + total: this.items.length, + currentServer: this.currentServer, + servers: this.servers, + }; + } + + public cancel(): void { + this.running = false; + } + + private async processSonarrSeries(sonarrSeries: SonarrSeries) { + const mediaRepository = getRepository(Media); + const server4k = this.enable4k && this.currentServer.is4k; + + const media = await mediaRepository.findOne({ + where: { tvdbId: sonarrSeries.tvdbId }, + }); + + const currentSeasonsAvailable = (media?.seasons ?? []).filter( + (season) => + season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE + ).length; + + const newSeasons: Season[] = []; + + for (const season of sonarrSeries.seasons) { + const existingSeason = media?.seasons.find( + (es) => es.seasonNumber === season.seasonNumber + ); + + // We are already tracking this season so we can work on it directly + if (existingSeason) { + if ( + existingSeason[server4k ? 'status4k' : 'status'] !== + MediaStatus.AVAILABLE && + season.statistics + ) { + existingSeason[server4k ? 'status4k' : 'status'] = + season.statistics.episodeFileCount === + season.statistics.totalEpisodeCount + ? MediaStatus.AVAILABLE + : season.statistics.episodeFileCount > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : season.monitored + ? MediaStatus.PROCESSING + : existingSeason[server4k ? 'status4k' : 'status']; + } + } else { + if (season.statistics && season.seasonNumber !== 0) { + const allEpisodes = + season.statistics.episodeFileCount === + season.statistics.totalEpisodeCount; + newSeasons.push( + new Season({ + seasonNumber: season.seasonNumber, + status: + !server4k && allEpisodes + ? MediaStatus.AVAILABLE + : !server4k && season.statistics.episodeFileCount > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : !server4k && season.monitored + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN, + status4k: + server4k && allEpisodes + ? MediaStatus.AVAILABLE + : server4k && season.statistics.episodeFileCount > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : !server4k && season.monitored + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN, + }) + ); + } + } + } + + const filteredSeasons = sonarrSeries.seasons.filter( + (s) => s.seasonNumber !== 0 + ); + + const isAllSeasons = + (media?.seasons ?? []).filter( + (s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE + ).length + + newSeasons.filter( + (s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE + ).length >= + filteredSeasons.length; + + if (media) { + media.seasons = [...media.seasons, ...newSeasons]; + + const newSeasonsAvailable = (media?.seasons ?? []).filter( + (season) => + season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE + ).length; + + if (newSeasonsAvailable > currentSeasonsAvailable) { + this.log( + `Detected ${newSeasonsAvailable - currentSeasonsAvailable} new ${ + server4k ? '4K ' : '' + }season(s) for ${sonarrSeries.title}`, + 'debug' + ); + media.lastSeasonChange = new Date(); + } + + if ( + media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id + ) { + media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id; + this.log(`Updated service ID for media entity: ${sonarrSeries.title}`); + } + + if ( + media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !== + sonarrSeries.id + ) { + media[server4k ? 'externalServiceId4k' : 'externalServiceId'] = + sonarrSeries.id; + this.log( + `Updated external service ID for media entity: ${sonarrSeries.title}` + ); + } + + if ( + media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !== + sonarrSeries.titleSlug + ) { + media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = + sonarrSeries.titleSlug; + this.log( + `Updated external service slug for media entity: ${sonarrSeries.title}` + ); + } + + media[server4k ? 'status4k' : 'status'] = isAllSeasons + ? MediaStatus.AVAILABLE + : media.seasons.some((season) => season.status !== MediaStatus.UNKNOWN) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN; + + await mediaRepository.save(media); + } else { + const tmdb = new TheMovieDb(); + let tvShow: TmdbTvDetails; + + try { + tvShow = await tmdb.getShowByTvdbId({ + tvdbId: sonarrSeries.tvdbId, + }); + } catch (e) { + this.log( + 'Failed to create new media item during sync. TVDB ID is missing from TMDB?', + 'warn', + { sonarrSeries, errorMessage: e.message } + ); + return; + } + + const newMedia = new Media({ + tmdbId: tvShow.id, + tvdbId: sonarrSeries.tvdbId, + mediaType: MediaType.TV, + serviceId: !server4k ? this.currentServer.id : undefined, + serviceId4k: server4k ? this.currentServer.id : undefined, + externalServiceId: !server4k ? sonarrSeries.id : undefined, + externalServiceId4k: server4k ? sonarrSeries.id : undefined, + externalServiceSlug: !server4k ? sonarrSeries.titleSlug : undefined, + externalServiceSlug4k: server4k ? sonarrSeries.titleSlug : undefined, + seasons: newSeasons, + status: + !server4k && isAllSeasons + ? MediaStatus.AVAILABLE + : !server4k && + newSeasons.some( + (s) => + s.status === MediaStatus.PARTIALLY_AVAILABLE || + s.status === MediaStatus.AVAILABLE + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : !server4k + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN, + status4k: + server4k && isAllSeasons + ? MediaStatus.AVAILABLE + : server4k && + newSeasons.some( + (s) => + s.status4k === MediaStatus.PARTIALLY_AVAILABLE || + s.status4k === MediaStatus.AVAILABLE + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : server4k + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN, + }); + + this.log( + `Added media for series ${sonarrSeries.title} and set status to ${ + MediaStatus[newMedia[server4k ? 'status4k' : 'status']] + }` + ); + await mediaRepository.save(newMedia); + } + } + + private async processItems(items: SonarrSeries[]) { + await Promise.all( + items.map(async (sonarrSeries) => { + await this.processSonarrSeries(sonarrSeries); + }) + ); + } + + private async loop({ + start = 0, + end = BUNDLE_SIZE, + sessionId, + }: { + start?: number; + end?: number; + sessionId?: string; + } = {}) { + const slicedItems = this.items.slice(start, end); + + if (!this.running) { + throw new Error('Sync was aborted.'); + } + + if (this.sessionId !== sessionId) { + throw new Error('New session was started. Old session aborted.'); + } + + if (start < this.items.length) { + this.progress = start; + await this.processItems(slicedItems); + + await new Promise((resolve, reject) => + setTimeout(() => { + this.loop({ + start: start + BUNDLE_SIZE, + end: end + BUNDLE_SIZE, + sessionId, + }) + .then(() => resolve()) + .catch((e) => reject(new Error(e.message))); + }, UPDATE_RATE) + ); + } + } + + private log( + message: string, + level: 'info' | 'error' | 'debug' | 'warn' = 'debug', + optional?: Record + ): void { + logger[level](message, { label: 'Sonarr Sync', ...optional }); + } +} + +export const jobSonarrSync = new JobSonarrSync(); diff --git a/server/lib/downloadtracker.ts b/server/lib/downloadtracker.ts new file mode 100644 index 000000000..9faf411a8 --- /dev/null +++ b/server/lib/downloadtracker.ts @@ -0,0 +1,195 @@ +import { uniqWith } from 'lodash'; +import RadarrAPI from '../api/radarr'; +import SonarrAPI from '../api/sonarr'; +import { MediaType } from '../constants/media'; +import logger from '../logger'; +import { getSettings } from './settings'; + +export interface DownloadingItem { + mediaType: MediaType; + externalId: number; + size: number; + sizeLeft: number; + status: string; + timeLeft: string; + estimatedCompletionTime: Date; + title: string; +} + +class DownloadTracker { + private radarrServers: Record = {}; + private sonarrServers: Record = {}; + + public getMovieProgress( + serverId: number, + externalServiceId: number + ): DownloadingItem[] { + if (!this.radarrServers[serverId]) { + return []; + } + + return this.radarrServers[serverId].filter( + (item) => item.externalId === externalServiceId + ); + } + + public getSeriesProgress( + serverId: number, + externalServiceId: number + ): DownloadingItem[] { + if (!this.sonarrServers[serverId]) { + return []; + } + + return this.sonarrServers[serverId].filter( + (item) => item.externalId === externalServiceId + ); + } + + public async resetDownloadTracker() { + this.radarrServers = {}; + } + + public updateDownloads() { + this.updateRadarrDownloads(); + this.updateSonarrDownloads(); + } + + private async updateRadarrDownloads() { + const settings = getSettings(); + + // Remove duplicate servers + const filteredServers = uniqWith(settings.radarr, (radarrA, radarrB) => { + return ( + radarrA.hostname === radarrB.hostname && + radarrA.port === radarrB.port && + radarrA.baseUrl === radarrB.baseUrl + ); + }); + + // Load downloads from Radarr servers + Promise.all( + filteredServers.map(async (server) => { + if (server.syncEnabled) { + const radarr = new RadarrAPI({ + apiKey: server.apiKey, + url: RadarrAPI.buildRadarrUrl(server, '/api/v3'), + }); + + const queueItems = await radarr.getQueue(); + + this.radarrServers[server.id] = queueItems.map((item) => ({ + externalId: item.movieId, + estimatedCompletionTime: new Date(item.estimatedCompletionTime), + mediaType: MediaType.MOVIE, + size: item.size, + sizeLeft: item.sizeleft, + status: item.status, + timeLeft: item.timeleft, + title: item.title, + })); + + if (queueItems.length > 0) { + logger.debug( + `Found ${queueItems.length} item(s) in progress on Radarr server: ${server.name}`, + { label: 'Download Tracker' } + ); + } + + // Duplicate this data to matching servers + const matchingServers = settings.radarr.filter( + (rs) => + rs.hostname === server.hostname && + rs.port === server.port && + rs.baseUrl === server.baseUrl && + rs.id !== server.id + ); + + if (matchingServers.length > 0) { + logger.debug( + `Matching download data to ${matchingServers.length} other Radarr server(s)`, + { label: 'Download Tracker' } + ); + } + + matchingServers.forEach((ms) => { + if (ms.syncEnabled) { + this.radarrServers[ms.id] = this.radarrServers[server.id]; + } + }); + } + }) + ); + } + + private async updateSonarrDownloads() { + const settings = getSettings(); + + // Remove duplicate servers + const filteredServers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => { + return ( + sonarrA.hostname === sonarrB.hostname && + sonarrA.port === sonarrB.port && + sonarrA.baseUrl === sonarrB.baseUrl + ); + }); + + // Load downloads from Radarr servers + Promise.all( + filteredServers.map(async (server) => { + if (server.syncEnabled) { + const radarr = new SonarrAPI({ + apiKey: server.apiKey, + url: SonarrAPI.buildSonarrUrl(server, '/api/v3'), + }); + + const queueItems = await radarr.getQueue(); + + this.sonarrServers[server.id] = queueItems.map((item) => ({ + externalId: item.seriesId, + estimatedCompletionTime: new Date(item.estimatedCompletionTime), + mediaType: MediaType.TV, + size: item.size, + sizeLeft: item.sizeleft, + status: item.status, + timeLeft: item.timeleft, + title: item.title, + })); + + if (queueItems.length > 0) { + logger.debug( + `Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`, + { label: 'Download Tracker' } + ); + } + + // Duplicate this data to matching servers + const matchingServers = settings.sonarr.filter( + (rs) => + rs.hostname === server.hostname && + rs.port === server.port && + rs.baseUrl === server.baseUrl && + rs.id !== server.id + ); + + if (matchingServers.length > 0) { + logger.debug( + `Matching download data to ${matchingServers.length} other Sonarr server(s)`, + { label: 'Download Tracker' } + ); + } + + matchingServers.forEach((ms) => { + if (ms.syncEnabled) { + this.sonarrServers[ms.id] = this.sonarrServers[server.id]; + } + }); + } + }) + ); + } +} + +const downloadTracker = new DownloadTracker(); + +export default downloadTracker; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 89bc3651a..1ec1024a6 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -32,6 +32,9 @@ interface DVRSettings { activeDirectory: string; is4k: boolean; isDefault: boolean; + externalUrl?: string; + syncEnabled: boolean; + preventSearch: boolean; } export interface RadarrSettings extends DVRSettings { diff --git a/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts b/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts new file mode 100644 index 000000000..49f47e403 --- /dev/null +++ b/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SonarrRadarrSyncServiceFields1611757511674 + implements MigrationInterface { + name = 'SonarrRadarrSyncServiceFields1611757511674'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query( + `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt" FROM "media"` + ); + await queryRunner.query(`DROP TABLE "media"`); + await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); + await queryRunner.query( + `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt" FROM "temporary_media"` + ); + await queryRunner.query(`DROP TABLE "temporary_media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + } +} diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 5a1230bbd..1d87c12e4 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -1,19 +1,10 @@ import { Router } from 'express'; -import { - getSettings, - RadarrSettings, - SonarrSettings, - Library, - MainSettings, -} from '../../lib/settings'; +import { getSettings, Library, MainSettings } from '../../lib/settings'; import { getRepository } from 'typeorm'; import { User } from '../../entity/User'; import PlexAPI from '../../api/plexapi'; import PlexTvAPI from '../../api/plextv'; import { jobPlexFullSync } from '../../job/plexsync'; -import SonarrAPI from '../../api/sonarr'; -import RadarrAPI from '../../api/radarr'; -import logger from '../../logger'; import { scheduledJobs } from '../../job/schedule'; import { Permission } from '../../lib/permissions'; import { isAuthenticated } from '../../middleware/auth'; @@ -23,10 +14,14 @@ import { MediaRequest } from '../../entity/MediaRequest'; import { getAppVersion } from '../../utils/appVersion'; import { SettingsAboutResponse } from '../../interfaces/api/settingsInterfaces'; import notificationRoutes from './notifications'; +import sonarrRoutes from './sonarr'; +import radarrRoutes from './radarr'; const settingsRoutes = Router(); settingsRoutes.use('/notifications', notificationRoutes); +settingsRoutes.use('/radarr', radarrRoutes); +settingsRoutes.use('/sonarr', sonarrRoutes); const filteredMainSettings = ( user: User, @@ -223,266 +218,60 @@ settingsRoutes.get('/plex/sync', (req, res) => { return res.status(200).json(jobPlexFullSync.status()); }); -settingsRoutes.get('/radarr', (_req, res) => { - const settings = getSettings(); - - res.status(200).json(settings.radarr); -}); - -settingsRoutes.post('/radarr', (req, res) => { - const settings = getSettings(); - - const newRadarr = req.body as RadarrSettings; - const lastItem = settings.radarr[settings.radarr.length - 1]; - newRadarr.id = lastItem ? lastItem.id + 1 : 0; - - // 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 = [...settings.radarr, newRadarr]; - settings.save(); - - return res.status(201).json(newRadarr); -}); - -settingsRoutes.post('/radarr/test', async (req, res, next) => { - try { - const radarr = new RadarrAPI({ - apiKey: req.body.apiKey, - url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${ - req.body.port - }${req.body.baseUrl ?? ''}/api`, - }); - - const profiles = await radarr.getProfiles(); - const folders = await radarr.getRootFolders(); - - return res.status(200).json({ - profiles, - rootFolders: folders.map((folder) => ({ - id: folder.id, - path: folder.path, - })), - }); - } catch (e) { - logger.error('Failed to test Radarr', { - label: 'Radarr', - message: e.message, - }); - - next({ status: 500, message: 'Failed to connect to Radarr' }); - } -}); - -settingsRoutes.put<{ id: string }>('/radarr/: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; - }); - } - - settings.radarr[radarrIndex] = { - ...req.body, - id: Number(req.params.id), - } as RadarrSettings; - settings.save(); - - return res.status(200).json(settings.radarr[radarrIndex]); -}); - -settingsRoutes.get<{ id: string }>('/radarr/:id/profiles', async (req, res) => { - const settings = getSettings(); - - const radarrSettings = settings.radarr.find( - (r) => r.id === Number(req.params.id) - ); - - if (!radarrSettings) { - return res - .status(404) - .json({ status: '404', message: 'Settings instance not found' }); - } - - const radarr = new RadarrAPI({ - apiKey: radarrSettings.apiKey, - url: `${radarrSettings.useSsl ? 'https' : 'http'}://${ - radarrSettings.hostname - }:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`, - }); - - const profiles = await radarr.getProfiles(); - +settingsRoutes.get('/jobs', (_req, res) => { return res.status(200).json( - profiles.map((profile) => ({ - id: profile.id, - name: profile.name, + scheduledJobs.map((job) => ({ + id: job.id, + name: job.name, + type: job.type, + nextExecutionTime: job.job.nextInvocation(), + running: job.running ? job.running() : false, })) ); }); -settingsRoutes.delete<{ id: string }>('/radarr/:id', (req, res) => { - const settings = getSettings(); - - const radarrIndex = settings.radarr.findIndex( - (r) => r.id === Number(req.params.id) - ); +settingsRoutes.get<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => { + const scheduledJob = scheduledJobs.find((job) => job.id === req.params.jobId); - if (radarrIndex === -1) { - return res - .status(404) - .json({ status: '404', message: 'Settings instance not found' }); + if (!scheduledJob) { + return next({ status: 404, message: 'Job not found' }); } - const removed = settings.radarr.splice(radarrIndex, 1); - settings.save(); - - return res.status(200).json(removed[0]); -}); - -settingsRoutes.get('/sonarr', (_req, res) => { - const settings = getSettings(); + scheduledJob.job.invoke(); - res.status(200).json(settings.sonarr); + return res.status(200).json({ + id: scheduledJob.id, + name: scheduledJob.name, + type: scheduledJob.type, + nextExecutionTime: scheduledJob.job.nextInvocation(), + running: scheduledJob.running ? scheduledJob.running() : false, + }); }); -settingsRoutes.post('/sonarr', (req, res) => { - const settings = getSettings(); - - const newSonarr = req.body as SonarrSettings; - const lastItem = settings.sonarr[settings.sonarr.length - 1]; - newSonarr.id = lastItem ? lastItem.id + 1 : 0; - - // 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.sonarr - .filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k) - .forEach((sonarrInstance) => { - sonarrInstance.isDefault = false; - }); - } - - settings.sonarr = [...settings.sonarr, newSonarr]; - settings.save(); - - return res.status(201).json(newSonarr); -}); +settingsRoutes.get<{ jobId: string }>( + '/jobs/:jobId/cancel', + (req, res, next) => { + const scheduledJob = scheduledJobs.find( + (job) => job.id === req.params.jobId + ); -settingsRoutes.post('/sonarr/test', async (req, res, next) => { - try { - const sonarr = new SonarrAPI({ - apiKey: req.body.apiKey, - url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${ - req.body.port - }${req.body.baseUrl ?? ''}/api`, - }); + if (!scheduledJob) { + return next({ status: 404, message: 'Job not found' }); + } - const profiles = await sonarr.getProfiles(); - const folders = await sonarr.getRootFolders(); + if (scheduledJob.cancelFn) { + scheduledJob.cancelFn(); + } return res.status(200).json({ - profiles, - rootFolders: folders.map((folder) => ({ - id: folder.id, - path: folder.path, - })), + id: scheduledJob.id, + name: scheduledJob.name, + type: scheduledJob.type, + nextExecutionTime: scheduledJob.job.nextInvocation(), + running: scheduledJob.running ? scheduledJob.running() : false, }); - } catch (e) { - logger.error('Failed to test Sonarr', { - label: 'Sonarr', - message: e.message, - }); - - next({ status: 500, message: 'Failed to connect to Sonarr' }); - } -}); - -settingsRoutes.put<{ id: string }>('/sonarr/:id', (req, res) => { - const settings = getSettings(); - - const sonarrIndex = settings.sonarr.findIndex( - (r) => r.id === Number(req.params.id) - ); - - if (sonarrIndex === -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.sonarr - .filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k) - .forEach((sonarrInstance) => { - sonarrInstance.isDefault = false; - }); - } - - settings.sonarr[sonarrIndex] = { - ...req.body, - id: Number(req.params.id), - } as SonarrSettings; - settings.save(); - - return res.status(200).json(settings.sonarr[sonarrIndex]); -}); - -settingsRoutes.delete<{ id: string }>('/sonarr/:id', (req, res) => { - const settings = getSettings(); - - const sonarrIndex = settings.sonarr.findIndex( - (r) => r.id === Number(req.params.id) - ); - - if (sonarrIndex === -1) { - return res - .status(404) - .json({ status: '404', message: 'Settings instance not found' }); - } - - const removed = settings.sonarr.splice(sonarrIndex, 1); - settings.save(); - - return res.status(200).json(removed[0]); -}); - -settingsRoutes.get('/jobs', (_req, res) => { - return res.status(200).json( - scheduledJobs.map((job) => ({ - name: job.name, - nextExecutionTime: job.job.nextInvocation(), - })) - ); -}); +); settingsRoutes.get( '/initialize', diff --git a/server/routes/settings/radarr.ts b/server/routes/settings/radarr.ts new file mode 100644 index 000000000..1bbcf2088 --- /dev/null +++ b/server/routes/settings/radarr.ts @@ -0,0 +1,149 @@ +import { Router } from 'express'; +import RadarrAPI from '../../api/radarr'; +import { getSettings, RadarrSettings } from '../../lib/settings'; +import logger from '../../logger'; + +const radarrRoutes = Router(); + +radarrRoutes.get('/', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.radarr); +}); + +radarrRoutes.post('/', (req, res) => { + const settings = getSettings(); + + const newRadarr = req.body as RadarrSettings; + const lastItem = settings.radarr[settings.radarr.length - 1]; + newRadarr.id = lastItem ? lastItem.id + 1 : 0; + + // 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 = [...settings.radarr, newRadarr]; + settings.save(); + + return res.status(201).json(newRadarr); +}); + +radarrRoutes.post('/test', async (req, res, next) => { + try { + const radarr = new RadarrAPI({ + apiKey: req.body.apiKey, + url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${ + req.body.port + }${req.body.baseUrl ?? ''}/api`, + }); + + const profiles = await radarr.getProfiles(); + const folders = await radarr.getRootFolders(); + + return res.status(200).json({ + profiles, + rootFolders: folders.map((folder) => ({ + id: folder.id, + path: folder.path, + })), + }); + } catch (e) { + logger.error('Failed to test Radarr', { + label: 'Radarr', + message: e.message, + }); + + next({ status: 500, message: 'Failed to connect to Radarr' }); + } +}); + +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; + }); + } + + 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) => { + const settings = getSettings(); + + const radarrSettings = settings.radarr.find( + (r) => r.id === Number(req.params.id) + ); + + if (!radarrSettings) { + return res + .status(404) + .json({ status: '404', message: 'Settings instance not found' }); + } + + const radarr = new RadarrAPI({ + apiKey: radarrSettings.apiKey, + url: `${radarrSettings.useSsl ? 'https' : 'http'}://${ + radarrSettings.hostname + }:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`, + }); + + const profiles = await radarr.getProfiles(); + + return res.status(200).json( + profiles.map((profile) => ({ + id: profile.id, + name: profile.name, + })) + ); +}); + +radarrRoutes.delete<{ 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' }); + } + + const removed = settings.radarr.splice(radarrIndex, 1); + settings.save(); + + return res.status(200).json(removed[0]); +}); + +export default radarrRoutes; diff --git a/server/routes/settings/sonarr.ts b/server/routes/settings/sonarr.ts new file mode 100644 index 000000000..409530f7d --- /dev/null +++ b/server/routes/settings/sonarr.ts @@ -0,0 +1,119 @@ +import { Router } from 'express'; +import SonarrAPI from '../../api/sonarr'; +import { getSettings, SonarrSettings } from '../../lib/settings'; +import logger from '../../logger'; + +const sonarrRoutes = Router(); + +sonarrRoutes.get('/', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.sonarr); +}); + +sonarrRoutes.post('/', (req, res) => { + const settings = getSettings(); + + const newSonarr = req.body as SonarrSettings; + const lastItem = settings.sonarr[settings.sonarr.length - 1]; + newSonarr.id = lastItem ? lastItem.id + 1 : 0; + + // 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.sonarr + .filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k) + .forEach((sonarrInstance) => { + sonarrInstance.isDefault = false; + }); + } + + settings.sonarr = [...settings.sonarr, newSonarr]; + settings.save(); + + return res.status(201).json(newSonarr); +}); + +sonarrRoutes.post('/test', async (req, res, next) => { + try { + const sonarr = new SonarrAPI({ + apiKey: req.body.apiKey, + url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${ + req.body.port + }${req.body.baseUrl ?? ''}/api`, + }); + + const profiles = await sonarr.getProfiles(); + const folders = await sonarr.getRootFolders(); + + return res.status(200).json({ + profiles, + rootFolders: folders.map((folder) => ({ + id: folder.id, + path: folder.path, + })), + }); + } catch (e) { + logger.error('Failed to test Sonarr', { + label: 'Sonarr', + message: e.message, + }); + + next({ status: 500, message: 'Failed to connect to Sonarr' }); + } +}); + +sonarrRoutes.put<{ id: string }>('/:id', (req, res) => { + const settings = getSettings(); + + const sonarrIndex = settings.sonarr.findIndex( + (r) => r.id === Number(req.params.id) + ); + + if (sonarrIndex === -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.sonarr + .filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k) + .forEach((sonarrInstance) => { + sonarrInstance.isDefault = false; + }); + } + + settings.sonarr[sonarrIndex] = { + ...req.body, + id: Number(req.params.id), + } as SonarrSettings; + settings.save(); + + return res.status(200).json(settings.sonarr[sonarrIndex]); +}); + +sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => { + const settings = getSettings(); + + const sonarrIndex = settings.sonarr.findIndex( + (r) => r.id === Number(req.params.id) + ); + + if (sonarrIndex === -1) { + return res + .status(404) + .json({ status: '404', message: 'Settings instance not found' }); + } + + const removed = settings.sonarr.splice(sonarrIndex, 1); + settings.save(); + + return res.status(200).json(removed[0]); +}); + +export default sonarrRoutes; diff --git a/src/assets/spinner.svg b/src/assets/spinner.svg new file mode 100644 index 000000000..a5ade8a11 --- /dev/null +++ b/src/assets/spinner.svg @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/src/components/Common/Badge/index.tsx b/src/components/Common/Badge/index.tsx index 62fd90728..1e9a5d56f 100644 --- a/src/components/Common/Badge/index.tsx +++ b/src/components/Common/Badge/index.tsx @@ -2,11 +2,16 @@ import React from 'react'; interface BadgeProps { badgeType?: 'default' | 'primary' | 'danger' | 'warning' | 'success'; + className?: string; } -const Badge: React.FC = ({ badgeType = 'default', children }) => { +const Badge: React.FC = ({ + badgeType = 'default', + className, + children, +}) => { const badgeStyle = [ - 'px-2 inline-flex text-xs leading-5 font-semibold rounded-full', + 'px-2 inline-flex text-xs leading-5 font-semibold rounded-full cursor-default', ]; switch (badgeType) { @@ -23,6 +28,10 @@ const Badge: React.FC = ({ badgeType = 'default', children }) => { badgeStyle.push('bg-indigo-500 text-indigo-100'); } + if (className) { + badgeStyle.push(className); + } + return {children}; }; diff --git a/src/components/Common/ConfirmButton/index.tsx b/src/components/Common/ConfirmButton/index.tsx new file mode 100644 index 000000000..1c48ead94 --- /dev/null +++ b/src/components/Common/ConfirmButton/index.tsx @@ -0,0 +1,57 @@ +import React, { useRef, useState } from 'react'; +import useClickOutside from '../../../hooks/useClickOutside'; +import Button from '../Button'; + +interface ConfirmButtonProps { + onClick: () => void; + confirmText: React.ReactNode; + className?: string; +} + +const ConfirmButton: React.FC = ({ + onClick, + children, + confirmText, + className, +}) => { + const ref = useRef(null); + useClickOutside(ref, () => setIsClicked(false)); + const [isClicked, setIsClicked] = useState(false); + return ( + + ); +}; + +export default ConfirmButton; diff --git a/src/components/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx index 6bc768d03..fd9b6c798 100644 --- a/src/components/Common/ListView/index.tsx +++ b/src/components/Common/ListView/index.tsx @@ -53,6 +53,9 @@ const ListView: React.FC = ({ userScore={title.voteAverage} year={title.releaseDate} mediaType={title.mediaType} + inProgress={ + (title.mediaInfo?.downloadStatus ?? []).length > 0 + } canExpand /> ); @@ -68,6 +71,9 @@ const ListView: React.FC = ({ userScore={title.voteAverage} year={title.firstAirDate} mediaType={title.mediaType} + inProgress={ + (title.mediaInfo?.downloadStatus ?? []).length > 0 + } canExpand /> ); @@ -87,7 +93,7 @@ const ListView: React.FC = ({ return (
  • {titleCard}
  • @@ -98,7 +104,7 @@ const ListView: React.FC = ({ [...Array(20)].map((_item, i) => (
  • diff --git a/src/components/DownloadBlock/index.tsx b/src/components/DownloadBlock/index.tsx new file mode 100644 index 000000000..3783d8c25 --- /dev/null +++ b/src/components/DownloadBlock/index.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { FormattedRelativeTime } from 'react-intl'; +import { DownloadingItem } from '../../../server/lib/downloadtracker'; +import Badge from '../Common/Badge'; + +interface DownloadBlockProps { + downloadItem: DownloadingItem; +} + +const DownloadBlock: React.FC = ({ downloadItem }) => { + return ( +
    +
    + {downloadItem.title} +
    +
    +
    +
    + + {Math.round( + ((downloadItem.size - downloadItem.sizeLeft) / + downloadItem.size) * + 100 + )} + % + +
    +
    +
    + {downloadItem.status} + + ETA{' '} + {downloadItem.estimatedCompletionTime ? ( + + ) : ( + 'N/A' + )} + +
    +
    + ); +}; + +export default DownloadBlock; diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index 0758a886d..14d832ee2 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -92,6 +92,7 @@ const MediaSlider: React.FC = ({ userScore={title.voteAverage} year={title.releaseDate} mediaType={title.mediaType} + inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0} /> ); case 'tv': @@ -105,6 +106,7 @@ const MediaSlider: React.FC = ({ userScore={title.voteAverage} year={title.firstAirDate} mediaType={title.mediaType} + inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0} /> ); case 'person': diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 37d53ece5..37d6088a8 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -33,6 +33,8 @@ import { sortCrewPriority } from '../../utils/creditHelpers'; import StatusBadge from '../StatusBadge'; import RequestButton from '../RequestButton'; import MediaSlider from '../MediaSlider'; +import ConfirmButton from '../Common/ConfirmButton'; +import DownloadBlock from '../DownloadBlock'; const messages = defineMessages({ releasedate: 'Release Date', @@ -63,6 +65,10 @@ const messages = defineMessages({ studio: 'Studio', viewfullcrew: 'View Full Crew', view: 'View', + areyousure: 'Are you sure?', + openradarr: 'Open Movie in Radarr', + openradarr4k: 'Open Movie in 4K Radarr', + downloadstatus: 'Download Status', }); interface MovieDetailsProps { @@ -127,6 +133,26 @@ const MovieDetails: React.FC = ({ movie }) => { onClose={() => setShowManager(false)} subText={data.title} > + {((data?.mediaInfo?.downloadStatus ?? []).length > 0 || + (data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && ( + <> +

    + {intl.formatMessage(messages.downloadstatus)} +

    +
    +
      + {data.mediaInfo?.downloadStatus?.map((status, index) => ( +
    • + +
    • + ))} +
    +
    + + )}

    {intl.formatMessage(messages.manageModalRequests)}

    @@ -147,15 +173,60 @@ const MovieDetails: React.FC = ({ movie }) => { )}
    + {(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && ( +
    + {data?.mediaInfo?.serviceUrl && ( + + + + )} + {data?.mediaInfo?.serviceUrl4k && ( + + + + )} +
    + )} {data?.mediaInfo && (
    - +
    {intl.formatMessage(messages.manageModalClearMediaWarning)}
    @@ -178,11 +249,18 @@ const MovieDetails: React.FC = ({ movie }) => {
    {data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && ( - + 0} + /> )} - + 0} + />

    diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 196db861e..42255c49a 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -120,6 +120,13 @@ const RequestCard: React.FC = ({ request }) => { : requestData.media.status } is4k={requestData.is4k} + inProgress={ + ( + requestData.media[ + requestData.is4k ? 'downloadStatus4k' : 'downloadStatus' + ] ?? [] + ).length > 0 + } />

    )} diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 7f2974a30..807555ef7 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -192,7 +192,16 @@ const RequestItem: React.FC = ({ : intl.formatMessage(globalMessages.failed)} ) : ( - + 0 + } + /> )} diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 7156a5980..3a331d894 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -210,7 +210,8 @@ const TvRequestModal: React.FC = ({ (season) => (season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || season[is4k ? 'status4k' : 'status'] === - MediaStatus.PARTIALLY_AVAILABLE) && + MediaStatus.PARTIALLY_AVAILABLE || + season[is4k ? 'status4k' : 'status'] === MediaStatus.PROCESSING) && !requestedSeasons.includes(season.seasonNumber) ) .map((season) => season.seasonNumber); @@ -509,13 +510,15 @@ const TvRequestModal: React.FC = ({ {intl.formatMessage(globalMessages.pending)} )} - {!mediaSeason && + {((!mediaSeason && seasonRequest?.status === - MediaRequestStatus.APPROVED && ( - - {intl.formatMessage(globalMessages.requested)} - - )} + MediaRequestStatus.APPROVED) || + mediaSeason?.[is4k ? 'status4k' : 'status'] === + MediaStatus.PROCESSING) && ( + + {intl.formatMessage(globalMessages.requested)} + + )} {!mediaSeason && seasonRequest?.status === MediaRequestStatus.AVAILABLE && ( diff --git a/src/components/Settings/RadarrModal/index.tsx b/src/components/Settings/RadarrModal/index.tsx index 735999611..2acb47110 100644 --- a/src/components/Settings/RadarrModal/index.tsx +++ b/src/components/Settings/RadarrModal/index.tsx @@ -35,6 +35,9 @@ const messages = defineMessages({ apiKeyPlaceholder: 'Your Radarr API key', baseUrl: 'Base URL', baseUrlPlaceholder: 'Example: /radarr', + syncEnabled: 'Enable Sync', + externalUrl: 'External URL', + externalUrlPlaceholder: 'External URL pointing to your Radarr server', qualityprofile: 'Quality Profile', rootfolder: 'Root Folder', minimumAvailability: 'Minimum Availability', @@ -46,6 +49,7 @@ const messages = defineMessages({ testFirstQualityProfiles: 'Test connection to load quality profiles', loadingrootfolders: 'Loading root folders…', testFirstRootFolders: 'Test connection to load root folders', + preventSearch: 'Disable Auto-Search', }); interface TestResponse { @@ -188,6 +192,9 @@ const RadarrModal: React.FC = ({ minimumAvailability: radarr?.minimumAvailability ?? 'released', isDefault: radarr?.isDefault ?? false, is4k: radarr?.is4k ?? false, + externalUrl: radarr?.externalUrl, + syncEnabled: radarr?.syncEnabled, + preventSearch: radarr?.preventSearch, }} validationSchema={RadarrSettingsSchema} onSubmit={async (values) => { @@ -209,6 +216,9 @@ const RadarrModal: React.FC = ({ is4k: values.is4k, minimumAvailability: values.minimumAvailability, isDefault: values.isDefault, + externalUrl: values.externalUrl, + syncEnabled: values.syncEnabled, + preventSearch: values.preventSearch, }; if (!radarr) { await axios.post('/api/v1/settings/radarr', submission); @@ -290,6 +300,22 @@ const RadarrModal: React.FC = ({ /> +
    + +
    + +
    +
    +
    + +
    +
    + +
    + {errors.externalUrl && touched.externalUrl && ( +
    + {errors.externalUrl} +
    + )} +
    +
    +
    +
    +
    + +
    +
    diff --git a/src/components/Settings/SettingsJobs.tsx b/src/components/Settings/SettingsJobs.tsx index 78e5e3f74..b8f1d3fec 100644 --- a/src/components/Settings/SettingsJobs.tsx +++ b/src/components/Settings/SettingsJobs.tsx @@ -4,35 +4,87 @@ import LoadingSpinner from '../Common/LoadingSpinner'; import { FormattedRelativeTime, defineMessages, useIntl } from 'react-intl'; import Button from '../Common/Button'; import Table from '../Common/Table'; +import Spinner from '../../assets/spinner.svg'; +import axios from 'axios'; +import { useToasts } from 'react-toast-notifications'; +import Badge from '../Common/Badge'; const messages = defineMessages({ jobname: 'Job Name', + jobtype: 'Type', nextexecution: 'Next Execution', runnow: 'Run Now', + canceljob: 'Cancel Job', + jobstarted: '{jobname} started.', + jobcancelled: '{jobname} cancelled.', }); +interface Job { + id: string; + name: string; + type: 'process' | 'command'; + nextExecutionTime: string; + running: boolean; +} + const SettingsJobs: React.FC = () => { const intl = useIntl(); - const { data, error } = useSWR<{ name: string; nextExecutionTime: string }[]>( - '/api/v1/settings/jobs' - ); + const { addToast } = useToasts(); + const { data, error, revalidate } = useSWR('/api/v1/settings/jobs', { + refreshInterval: 5000, + }); if (!data && !error) { return ; } + const runJob = async (job: Job) => { + await axios.get(`/api/v1/settings/jobs/${job.id}/run`); + addToast( + intl.formatMessage(messages.jobstarted, { + jobname: job.name, + }), + { + appearance: 'success', + autoDismiss: true, + } + ); + revalidate(); + }; + + const cancelJob = async (job: Job) => { + await axios.get(`/api/v1/settings/jobs/${job.id}/cancel`); + addToast(intl.formatMessage(messages.jobcancelled, { jobname: job.name }), { + appearance: 'error', + autoDismiss: true, + }); + revalidate(); + }; + return ( + {intl.formatMessage(messages.jobname)}{intl.formatMessage(messages.jobname)}{intl.formatMessage(messages.nextexecution)} - {data?.map((job, index) => ( - + {data?.map((job) => ( + + +
    + {job.running && } + {job.name} +
    +
    -
    {job.name}
    + + {job.type} +
    @@ -46,9 +98,15 @@ const SettingsJobs: React.FC = () => {
    - + {job.running ? ( + + ) : ( + + )} ))} diff --git a/src/components/Settings/SonarrModal/index.tsx b/src/components/Settings/SonarrModal/index.tsx index c33732f35..cc2cea6c2 100644 --- a/src/components/Settings/SonarrModal/index.tsx +++ b/src/components/Settings/SonarrModal/index.tsx @@ -46,6 +46,10 @@ const messages = defineMessages({ testFirstQualityProfiles: 'Test connection to load quality profiles', loadingrootfolders: 'Loading root folders…', testFirstRootFolders: 'Test connection to load root folders', + syncEnabled: 'Enable Sync', + externalUrl: 'External URL', + externalUrlPlaceholder: 'External URL pointing to your Sonarr server', + preventSearch: 'Disable Auto-Search', }); interface TestResponse { @@ -189,6 +193,9 @@ const SonarrModal: React.FC = ({ isDefault: sonarr?.isDefault ?? false, is4k: sonarr?.is4k ?? false, enableSeasonFolders: sonarr?.enableSeasonFolders ?? false, + externalUrl: sonarr?.externalUrl, + syncEnabled: sonarr?.syncEnabled ?? false, + preventSearch: sonarr?.preventSearch ?? false, }} validationSchema={SonarrSettingsSchema} onSubmit={async (values) => { @@ -218,6 +225,9 @@ const SonarrModal: React.FC = ({ is4k: values.is4k, isDefault: values.isDefault, enableSeasonFolders: values.enableSeasonFolders, + externalUrl: values.externalUrl, + syncEnabled: values.syncEnabled, + preventSearch: values.preventSearch, }; if (!sonarr) { await axios.post('/api/v1/settings/sonarr', submission); @@ -299,6 +309,22 @@ const SonarrModal: React.FC = ({ /> +
    + +
    + +
    +
    +
    + +
    +
    + +
    + {errors.externalUrl && touched.externalUrl && ( +
    + {errors.externalUrl} +
    + )} +
    +
    +
    +
    +
    + +
    +
    diff --git a/src/components/StatusBadge/index.tsx b/src/components/StatusBadge/index.tsx index fcc2d0d68..6461e6f89 100644 --- a/src/components/StatusBadge/index.tsx +++ b/src/components/StatusBadge/index.tsx @@ -3,6 +3,7 @@ import { MediaStatus } from '../../../server/constants/media'; import Badge from '../Common/Badge'; import { defineMessages, useIntl } from 'react-intl'; import globalMessages from '../../i18n/globalMessages'; +import Spinner from '../../assets/spinner.svg'; const messages = defineMessages({ status4k: '4K {status}', @@ -11,9 +12,14 @@ const messages = defineMessages({ interface StatusBadgeProps { status?: MediaStatus; is4k?: boolean; + inProgress?: boolean; } -const StatusBadge: React.FC = ({ status, is4k }) => { +const StatusBadge: React.FC = ({ + status, + is4k = false, + inProgress = false, +}) => { const intl = useIntl(); if (is4k) { @@ -37,9 +43,16 @@ const StatusBadge: React.FC = ({ status, is4k }) => { case MediaStatus.PROCESSING: return ( - {intl.formatMessage(messages.status4k, { - status: intl.formatMessage(globalMessages.requested), - })} +
    + + {intl.formatMessage(messages.status4k, { + status: inProgress + ? intl.formatMessage(globalMessages.processing) + : intl.formatMessage(globalMessages.requested), + })} + + {inProgress && } +
    ); case MediaStatus.PENDING: @@ -59,19 +72,32 @@ const StatusBadge: React.FC = ({ status, is4k }) => { case MediaStatus.AVAILABLE: return ( - {intl.formatMessage(globalMessages.available)} +
    + {intl.formatMessage(globalMessages.available)} + {inProgress && } +
    ); case MediaStatus.PARTIALLY_AVAILABLE: return ( - {intl.formatMessage(globalMessages.partiallyavailable)} +
    + {intl.formatMessage(globalMessages.partiallyavailable)} + {inProgress && } +
    ); case MediaStatus.PROCESSING: return ( - {intl.formatMessage(globalMessages.requested)} +
    + + {inProgress + ? intl.formatMessage(globalMessages.processing) + : intl.formatMessage(globalMessages.requested)} + + {inProgress && } +
    ); case MediaStatus.PENDING: diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 81aa4e091..b4d8ba546 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -9,6 +9,7 @@ import RequestModal from '../RequestModal'; import { defineMessages, useIntl } from 'react-intl'; import { useIsTouch } from '../../hooks/useIsTouch'; import globalMessages from '../../i18n/globalMessages'; +import Spinner from '../../assets/spinner.svg'; const messages = defineMessages({ movie: 'Movie', @@ -25,6 +26,7 @@ interface TitleCardProps { mediaType: MediaType; status?: MediaStatus; canExpand?: boolean; + inProgress?: boolean; } const TitleCard: React.FC = ({ @@ -35,6 +37,7 @@ const TitleCard: React.FC = ({ title, status, mediaType, + inProgress = false, canExpand = false, }) => { const isTouch = useIsTouch(); @@ -146,18 +149,22 @@ const TitleCard: React.FC = ({ )} {currentStatus === MediaStatus.PROCESSING && (
    - - - + {inProgress ? ( + + ) : ( + + + + )}
    )}
    diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 98657d600..81526c20c 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -35,6 +35,8 @@ import { Crew } from '../../../server/models/common'; import StatusBadge from '../StatusBadge'; import RequestButton from '../RequestButton'; import MediaSlider from '../MediaSlider'; +import ConfirmButton from '../Common/ConfirmButton'; +import DownloadBlock from '../DownloadBlock'; const messages = defineMessages({ firstAirDate: 'First Air Date', @@ -63,6 +65,10 @@ const messages = defineMessages({ anime: 'Anime', network: 'Network', viewfullcrew: 'View Full Crew', + areyousure: 'Are you sure?', + opensonarr: 'Open Series in Sonarr', + opensonarr4k: 'Open Series in 4K Sonarr', + downloadstatus: 'Download Status', }); interface TvDetailsProps { @@ -154,6 +160,26 @@ const TvDetails: React.FC = ({ tv }) => { onClose={() => setShowManager(false)} subText={data.name} > + {((data?.mediaInfo?.downloadStatus ?? []).length > 0 || + (data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && ( + <> +

    + {intl.formatMessage(messages.downloadstatus)} +

    +
    +
      + {data.mediaInfo?.downloadStatus?.map((status, index) => ( +
    • + +
    • + ))} +
    +
    + + )}

    {intl.formatMessage(messages.manageModalRequests)}

    @@ -174,15 +200,60 @@ const TvDetails: React.FC = ({ tv }) => { )} + {(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && ( +
    + {data?.mediaInfo?.serviceUrl && ( + + + + )} + {data?.mediaInfo?.serviceUrl4k && ( + + + + )} +
    + )} {data?.mediaInfo && (
    - +
    {intl.formatMessage(messages.manageModalClearMediaWarning)}
    @@ -205,11 +276,18 @@ const TvDetails: React.FC = ({ tv }) => {
    {data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && ( - + 0} + /> )} - + 0} + />

    diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 1d4654d0c..2709681b9 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -41,16 +41,20 @@ "components.MovieDetails.MovieCast.fullcast": "Full Cast", "components.MovieDetails.MovieCrew.fullcrew": "Full Crew", "components.MovieDetails.approve": "Approve", + "components.MovieDetails.areyousure": "Are you sure?", "components.MovieDetails.available": "Available", "components.MovieDetails.budget": "Budget", "components.MovieDetails.cancelrequest": "Cancel Request", "components.MovieDetails.cast": "Cast", "components.MovieDetails.decline": "Decline", + "components.MovieDetails.downloadstatus": "Download Status", "components.MovieDetails.manageModalClearMedia": "Clear All Media Data", "components.MovieDetails.manageModalClearMediaWarning": "This will irreversibly remove all data for this movie, including any requests. If this item exists in your Plex library, the media information will be recreated during the next sync.", "components.MovieDetails.manageModalNoRequests": "No Requests", "components.MovieDetails.manageModalRequests": "Requests", "components.MovieDetails.manageModalTitle": "Manage Movie", + "components.MovieDetails.openradarr": "Open Movie in Radarr", + "components.MovieDetails.openradarr4k": "Open Movie in 4K Radarr", "components.MovieDetails.originallanguage": "Original Language", "components.MovieDetails.overview": "Overview", "components.MovieDetails.overviewunavailable": "Overview unavailable.", @@ -284,11 +288,14 @@ "components.Settings.RadarrModal.createradarr": "Create New Radarr Server", "components.Settings.RadarrModal.defaultserver": "Default Server", "components.Settings.RadarrModal.editradarr": "Edit Radarr Server", + "components.Settings.RadarrModal.externalUrl": "External URL", + "components.Settings.RadarrModal.externalUrlPlaceholder": "External URL pointing to your Radarr server", "components.Settings.RadarrModal.hostname": "Hostname", "components.Settings.RadarrModal.loadingprofiles": "Loading quality profiles…", "components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…", "components.Settings.RadarrModal.minimumAvailability": "Minimum Availability", "components.Settings.RadarrModal.port": "Port", + "components.Settings.RadarrModal.preventSearch": "Disable Auto-Search", "components.Settings.RadarrModal.qualityprofile": "Quality Profile", "components.Settings.RadarrModal.rootfolder": "Root Folder", "components.Settings.RadarrModal.save": "Save Changes", @@ -300,6 +307,7 @@ "components.Settings.RadarrModal.servername": "Server Name", "components.Settings.RadarrModal.servernamePlaceholder": "A Radarr Server", "components.Settings.RadarrModal.ssl": "SSL", + "components.Settings.RadarrModal.syncEnabled": "Enable Sync", "components.Settings.RadarrModal.test": "Test", "components.Settings.RadarrModal.testFirstQualityProfiles": "Test connection to load quality profiles", "components.Settings.RadarrModal.testFirstRootFolders": "Test connection to load root folders", @@ -343,10 +351,13 @@ "components.Settings.SonarrModal.createsonarr": "Create New Sonarr Server", "components.Settings.SonarrModal.defaultserver": "Default Server", "components.Settings.SonarrModal.editsonarr": "Edit Sonarr Server", + "components.Settings.SonarrModal.externalUrl": "External URL", + "components.Settings.SonarrModal.externalUrlPlaceholder": "External URL pointing to your Sonarr server", "components.Settings.SonarrModal.hostname": "Hostname", "components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…", "components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…", "components.Settings.SonarrModal.port": "Port", + "components.Settings.SonarrModal.preventSearch": "Disable Auto-Search", "components.Settings.SonarrModal.qualityprofile": "Quality Profile", "components.Settings.SonarrModal.rootfolder": "Root Folder", "components.Settings.SonarrModal.save": "Save Changes", @@ -358,6 +369,7 @@ "components.Settings.SonarrModal.servername": "Server Name", "components.Settings.SonarrModal.servernamePlaceholder": "A Sonarr Server", "components.Settings.SonarrModal.ssl": "SSL", + "components.Settings.SonarrModal.syncEnabled": "Enable Sync", "components.Settings.SonarrModal.test": "Test", "components.Settings.SonarrModal.testFirstQualityProfiles": "Test connection to load quality profiles", "components.Settings.SonarrModal.testFirstRootFolders": "Test connection to load root folders", @@ -377,6 +389,7 @@ "components.Settings.apikey": "API Key", "components.Settings.applicationurl": "Application URL", "components.Settings.autoapprovedrequests": "Send Notifications for Auto-Approved Requests", + "components.Settings.canceljob": "Cancel Job", "components.Settings.cancelscan": "Cancel Scan", "components.Settings.copied": "Copied API key to clipboard.", "components.Settings.csrfProtection": "Enable CSRF Protection", @@ -393,7 +406,10 @@ "components.Settings.generalsettingsDescription": "Configure global and default settings for Overseerr.", "components.Settings.hideAvailable": "Hide Available Media", "components.Settings.hostname": "Hostname/IP", + "components.Settings.jobcancelled": "{jobname} cancelled.", "components.Settings.jobname": "Job Name", + "components.Settings.jobstarted": "{jobname} started.", + "components.Settings.jobtype": "Type", "components.Settings.librariesRemaining": "Libraries Remaining: {count}", "components.Settings.manualscan": "Manual Library Scan", "components.Settings.manualscanDescription": "Normally, this will only be run once every 24 hours. Overseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!", @@ -478,10 +494,12 @@ "components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew", "components.TvDetails.anime": "Anime", "components.TvDetails.approve": "Approve", + "components.TvDetails.areyousure": "Are you sure?", "components.TvDetails.available": "Available", "components.TvDetails.cancelrequest": "Cancel Request", "components.TvDetails.cast": "Cast", "components.TvDetails.decline": "Decline", + "components.TvDetails.downloadstatus": "Download Status", "components.TvDetails.firstAirDate": "First Air Date", "components.TvDetails.manageModalClearMedia": "Clear All Media Data", "components.TvDetails.manageModalClearMediaWarning": "This will irreversibly remove all data for this TV series, including any requests. If this item exists in your Plex library, the media information will be recreated during the next sync.", @@ -489,6 +507,8 @@ "components.TvDetails.manageModalRequests": "Requests", "components.TvDetails.manageModalTitle": "Manage Series", "components.TvDetails.network": "Network", + "components.TvDetails.opensonarr": "Open Series in Sonarr", + "components.TvDetails.opensonarr4k": "Open Series in 4K Sonarr", "components.TvDetails.originallanguage": "Original Language", "components.TvDetails.overview": "Overview", "components.TvDetails.overviewunavailable": "Overview unavailable.", @@ -507,6 +527,7 @@ "components.UserEdit.edituser": "Edit User", "components.UserEdit.email": "Email", "components.UserEdit.permissions": "Permissions", + "components.UserEdit.plexUsername": "Plex Username", "components.UserEdit.save": "Save", "components.UserEdit.saving": "Saving…", "components.UserEdit.userfail": "Something went wrong while saving the user.",