From 2e7458457e995dd3ec6dd96035fe997646cdd446 Mon Sep 17 00:00:00 2001 From: dd060606 Date: Fri, 8 Jul 2022 10:51:19 +0200 Subject: [PATCH 1/4] feat: add a button in ManageSlideOver to remove the movie and the file from Radarr/Sonarr --- overseerr-api.yml | 17 ++++ server/api/servarr/radarr.ts | 14 +++ server/api/servarr/sonarr.ts | 14 +++ server/routes/media.ts | 97 ++++++++++++++++++++ src/components/ManageSlideOver/index.tsx | 111 +++++++++++++++++++---- 5 files changed, 237 insertions(+), 16 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index 551f7dd9..24a0ed10 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -5362,6 +5362,23 @@ paths: responses: '204': description: Succesfully removed media item + /media/{mediaId}/file: + delete: + summary: Delete media file + description: Removes a media file from radarr/sonarr. The `ADMIN` permission is required to perform this action. + tags: + - media + parameters: + - in: path + name: mediaId + description: Media ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed media item /media/{mediaId}/{status}: post: summary: Update media status diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts index 7305baf0..6064fa30 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -213,6 +213,20 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { ); } } + public removeMovie = async (movieId: number): Promise => { + try { + const { id, title } = await this.getMovieByTmdbId(movieId); + await this.axios.delete(`/movie/${id}`, { + params: { + deleteFiles: true, + addImportExclusion: false, + }, + }); + logger.info(`[Radarr] Removed movie ${title}`); + } catch (e) { + throw new Error(`[Radarr] Failed to remove movie: ${e.message}`); + } + }; } export default RadarrAPI; diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 7440d278..fdc00aad 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -302,6 +302,20 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> { return newSeasons; } + public removeSerie = async (serieId: number): Promise => { + try { + const { id, title } = await this.getSeriesByTvdbId(serieId); + await this.axios.delete(`/series/${id}`, { + params: { + deleteFiles: true, + addImportExclusion: false, + }, + }); + logger.info(`[Radarr] Removed serie ${title}`); + } catch (e) { + throw new Error(`[Radarr] Failed to remove serie: ${e.message}`); + } + }; } export default SonarrAPI; diff --git a/server/routes/media.ts b/server/routes/media.ts index 429b2010..73f08cab 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -1,6 +1,9 @@ import { Router } from 'express'; import { FindOneOptions, FindOperator, getRepository, In } from 'typeorm'; +import RadarrAPI from '../api/servarr/radarr'; +import SonarrAPI from '../api/servarr/sonarr'; import TautulliAPI from '../api/tautulli'; +import TheMovieDb from '../api/themoviedb'; import { MediaStatus, MediaType } from '../constants/media'; import Media from '../entity/Media'; import { User } from '../entity/User'; @@ -167,6 +170,100 @@ mediaRoutes.delete( } ); +mediaRoutes.delete( + '/:id/file', + isAuthenticated(Permission.MANAGE_REQUESTS), + async (req, res, next) => { + try { + const settings = getSettings(); + const mediaRepository = getRepository(Media); + const media = await mediaRepository.findOneOrFail({ + where: { id: req.params.id }, + }); + const is4k = media.serviceUrl4k !== undefined; + const isMovie = media.mediaType === MediaType.MOVIE; + let serviceSettings; + if (isMovie) { + serviceSettings = settings.radarr.find( + (radarr) => radarr.isDefault && radarr.is4k === is4k + ); + } else { + serviceSettings = settings.sonarr.find( + (sonarr) => sonarr.isDefault && sonarr.is4k === is4k + ); + } + + if ( + media.serviceId && + media.serviceId >= 0 && + serviceSettings?.id !== media.serviceId + ) { + if (isMovie) { + serviceSettings = settings.radarr.find( + (radarr) => radarr.id === media.serviceId + ); + } else { + serviceSettings = settings.sonarr.find( + (sonarr) => sonarr.id === media.serviceId + ); + } + } + if (!serviceSettings) { + logger.warn( + `There is no default ${ + is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr' + }/ server configured. Did you set any of your ${ + is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr' + } servers as default?`, + { + label: 'Media Request', + mediaId: media.id, + } + ); + return; + } + let service; + if (isMovie) { + service = new RadarrAPI({ + apiKey: serviceSettings?.apiKey, + url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'), + }); + } else { + service = new SonarrAPI({ + apiKey: serviceSettings?.apiKey, + url: SonarrAPI.buildUrl(serviceSettings, '/api/v3'), + }); + } + + if (isMovie) { + await (service as RadarrAPI).removeMovie( + parseInt( + is4k + ? (media.externalServiceSlug4k as string) + : (media.externalServiceSlug as string) + ) + ); + } else { + const tmdb = new TheMovieDb(); + const series = await tmdb.getTvShow({ tvId: media.tmdbId }); + const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; + if (!tvdbId) { + throw new Error('TVDB ID not found'); + } + await (service as SonarrAPI).removeSerie(tvdbId); + } + + return res.status(204).send(); + } catch (e) { + logger.error('Something went wrong fetching media in delete request', { + label: 'Media', + message: e.message, + }); + next({ status: 404, message: 'Media not found' }); + } + } +); + mediaRoutes.get<{ id: string }, MediaWatchDataResponse>( '/:id/watch_data', isAuthenticated(Permission.ADMIN), diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index a1e9bab1..3d2c590d 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -1,5 +1,9 @@ import { ServerIcon, ViewListIcon } from '@heroicons/react/outline'; -import { CheckCircleIcon, DocumentRemoveIcon } from '@heroicons/react/solid'; +import { + CheckCircleIcon, + DocumentRemoveIcon, + TrashIcon, +} from '@heroicons/react/solid'; import axios from 'axios'; import Link from 'next/link'; import React from 'react'; @@ -34,8 +38,12 @@ const messages = defineMessages({ manageModalClearMedia: 'Clear Data', manageModalClearMediaWarning: '* This will irreversibly remove all data for this {mediaType}, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.', + manageModalRemoveMediaWarning: + '* This will irreversibly remove this {mediaType} from {arr}, including all files.', openarr: 'Open in {arr}', + removearr: 'Remove from {arr}', openarr4k: 'Open in 4K {arr}', + removearr4k: 'Remove from 4K {arr}', downloadstatus: 'Downloads', markavailable: 'Mark as Available', mark4kavailable: 'Mark as Available in 4K', @@ -90,6 +98,13 @@ const ManageSlideOver: React.FC< revalidate(); } }; + const deleteMediaFile = async () => { + if (data.mediaInfo) { + await axios.delete(`/api/v1/media/${data.mediaInfo.id}/file`); + await axios.delete(`/api/v1/media/${data.mediaInfo.id}`); + revalidate(); + } + }; const markAvailable = async (is4k = false) => { if (data.mediaInfo) { @@ -319,6 +334,39 @@ const ManageSlideOver: React.FC< )} + + {hasPermission(Permission.ADMIN) && + data?.mediaInfo?.serviceUrl && ( +
+ deleteMediaFile()} + confirmText={intl.formatMessage( + globalMessages.areyousure + )} + className="w-full" + > + + + {intl.formatMessage(messages.removearr, { + arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + })} + + +
+ {intl.formatMessage( + messages.manageModalRemoveMediaWarning, + { + mediaType: intl.formatMessage( + mediaType === 'movie' + ? messages.movie + : messages.tvshow + ), + arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + } + )} +
+
+ )} )} @@ -418,21 +466,52 @@ const ManageSlideOver: React.FC< )} {data?.mediaInfo?.serviceUrl4k && ( - - - + <> + + + +
+ deleteMediaFile()} + confirmText={intl.formatMessage( + globalMessages.areyousure + )} + className="w-full" + > + + + {intl.formatMessage(messages.removearr4k, { + arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + })} + + +
+ {intl.formatMessage( + messages.manageModalRemoveMediaWarning, + { + mediaType: intl.formatMessage( + mediaType === 'movie' + ? messages.movie + : messages.tvshow + ), + arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + } + )} +
+
+ )} From 7d4455ba6bfd12e2730f7085cbb87df246f01d22 Mon Sep 17 00:00:00 2001 From: dd060606 Date: Sun, 14 Aug 2022 12:07:12 +0200 Subject: [PATCH 2/4] fix: hide remove button when default service is not configured --- src/components/ManageSlideOver/index.tsx | 90 ++++++++++++++++-------- 1 file changed, 61 insertions(+), 29 deletions(-) diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index 3d2c590d..9fbd4c6b 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -13,8 +13,10 @@ import { IssueStatus } from '../../../server/constants/issue'; import { MediaRequestStatus, MediaStatus, + MediaType, } from '../../../server/constants/media'; import { MediaWatchDataResponse } from '../../../server/interfaces/api/mediaInterfaces'; +import { RadarrSettings, SonarrSettings } from '../../../server/lib/settings'; import { MovieDetails } from '../../../server/models/Movie'; import { TvDetails } from '../../../server/models/Tv'; import useSettings from '../../hooks/useSettings'; @@ -91,6 +93,12 @@ const ManageSlideOver: React.FC< ? `/api/v1/media/${data.mediaInfo.id}/watch_data` : null ); + const { data: radarrData } = useSWR( + '/api/v1/settings/radarr' + ); + const { data: sonarrData } = useSWR( + '/api/v1/settings/sonarr' + ); const deleteMedia = async () => { if (data.mediaInfo) { @@ -106,6 +114,27 @@ const ManageSlideOver: React.FC< } }; + const isDefaultService = () => { + if (data.mediaInfo) { + if (data.mediaInfo.mediaType === MediaType.MOVIE) { + return ( + radarrData?.find( + (radarr) => + radarr.isDefault && radarr.id === data.mediaInfo?.serviceId + ) !== undefined + ); + } else { + return ( + sonarrData?.find( + (sonarr) => + sonarr.isDefault && sonarr.id === data.mediaInfo?.serviceId + ) !== undefined + ); + } + } + return false; + }; + const markAvailable = async (is4k = false) => { if (data.mediaInfo) { await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, { @@ -336,7 +365,8 @@ const ManageSlideOver: React.FC< )} {hasPermission(Permission.ADMIN) && - data?.mediaInfo?.serviceUrl && ( + data?.mediaInfo?.serviceUrl && + isDefaultService() && (
deleteMediaFile()} @@ -482,35 +512,37 @@ const ManageSlideOver: React.FC< -
- deleteMediaFile()} - confirmText={intl.formatMessage( - globalMessages.areyousure - )} - className="w-full" - > - - - {intl.formatMessage(messages.removearr4k, { - arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', - })} - - -
- {intl.formatMessage( - messages.manageModalRemoveMediaWarning, - { - mediaType: intl.formatMessage( - mediaType === 'movie' - ? messages.movie - : messages.tvshow - ), - arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', - } - )} + {isDefaultService() && ( +
+ deleteMediaFile()} + confirmText={intl.formatMessage( + globalMessages.areyousure + )} + className="w-full" + > + + + {intl.formatMessage(messages.removearr4k, { + arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + })} + + +
+ {intl.formatMessage( + messages.manageModalRemoveMediaWarning, + { + mediaType: intl.formatMessage( + mediaType === 'movie' + ? messages.movie + : messages.tvshow + ), + arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + } + )} +
-
+ )} )}
From bcd2bb7c96810f5a6932f42468a628d2db1bc771 Mon Sep 17 00:00:00 2001 From: dd060606 Date: Wed, 28 Sep 2022 15:55:56 +0200 Subject: [PATCH 3/4] fix: lint issues --- server/routes/media.ts | 8 ++++---- src/components/ManageSlideOver/index.tsx | 10 +++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/server/routes/media.ts b/server/routes/media.ts index 7ca1c3ad..60191e5d 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -1,8 +1,7 @@ -import { FindOneOptions, FindOperator, getRepository, In } from 'typeorm'; import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; -import TheMovieDb from '@server/api/themoviedb'; import TautulliAPI from '@server/api/tautulli'; +import TheMovieDb from '@server/api/themoviedb'; import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; @@ -16,7 +15,8 @@ import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; - +import type { FindOneOptions } from 'typeorm'; +import { In } from 'typeorm'; const mediaRoutes = Router(); @@ -179,7 +179,7 @@ mediaRoutes.delete( const settings = getSettings(); const mediaRepository = getRepository(Media); const media = await mediaRepository.findOneOrFail({ - where: { id: req.params.id }, + where: { id: Number(req.params.id) }, }); const is4k = media.serviceUrl4k !== undefined; const isMovie = media.mediaType === MediaType.MOVIE; diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index f7597404..ea798563 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -14,17 +14,20 @@ import { TrashIcon, } from '@heroicons/react/solid'; import { IssueStatus } from '@server/constants/issue'; -import { MediaRequestStatus, MediaStatus,MediaType } from '@server/constants/media'; +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfaces'; +import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import type { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import Link from 'next/link'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { RadarrSettings, SonarrSettings } from '@server/lib/settings'; - const messages = defineMessages({ manageModalTitle: 'Manage {mediaType}', @@ -109,6 +112,7 @@ const ManageSlideOver = ({ revalidate(); } }; + const deleteMediaFile = async () => { if (data.mediaInfo) { await axios.delete(`/api/v1/media/${data.mediaInfo.id}/file`); From 3005e577d7c8e6338055d86a1f642950a4d38dad Mon Sep 17 00:00:00 2001 From: dd060606 Date: Mon, 13 Feb 2023 08:33:23 +0100 Subject: [PATCH 4/4] style(src/components/manageslideover/index.tsx): fix code style issues --- src/components/ManageSlideOver/index.tsx | 25 +++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index ea798563..ad33d653 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -7,12 +7,14 @@ import RequestBlock from '@app/components/RequestBlock'; import useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; -import { ServerIcon, ViewListIcon } from '@heroicons/react/outline'; + +import { Bars4Icon, ServerIcon } from '@heroicons/react/24/outline'; import { CheckCircleIcon, - DocumentRemoveIcon, + DocumentMinusIcon, TrashIcon, -} from '@heroicons/react/solid'; +} from '@heroicons/react/24/solid'; + import { IssueStatus } from '@server/constants/issue'; import { MediaRequestStatus, @@ -25,6 +27,7 @@ import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import type { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; +import getConfig from 'next/config'; import Link from 'next/link'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; @@ -39,7 +42,7 @@ const messages = defineMessages({ manageModalNoRequests: 'No requests.', manageModalClearMedia: 'Clear Data', manageModalClearMediaWarning: - '* This will irreversibly remove all data for this {mediaType}, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.', + '* This will irreversibly remove all data for this {mediaType}, including any requests. If this item exists in your {mediaServerName} library, the media information will be recreated during the next scan.', manageModalRemoveMediaWarning: '* This will irreversibly remove this {mediaType} from {arr}, including all files.', openarr: 'Open in {arr}', @@ -92,6 +95,7 @@ const ManageSlideOver = ({ const { user: currentUser, hasPermission } = useUser(); const intl = useIntl(); const settings = useSettings(); + const { publicRuntimeConfig } = getConfig(); const { data: watchData } = useSWR( settings.currentSettings.mediaServerType === MediaServerType.PLEX && data.mediaInfo && @@ -348,7 +352,7 @@ const ManageSlideOver = ({ watchData?.data ? 'rounded-t-none' : '' }`} > - + {intl.formatMessage(messages.opentautulli)} @@ -503,7 +507,7 @@ const ManageSlideOver = ({ watchData?.data4k ? 'rounded-t-none' : '' }`} > - + {intl.formatMessage(messages.opentautulli)} @@ -610,7 +614,7 @@ const ManageSlideOver = ({ confirmText={intl.formatMessage(globalMessages.areyousure)} className="w-full" > - + {intl.formatMessage(messages.manageModalClearMedia)} @@ -620,6 +624,13 @@ const ManageSlideOver = ({ mediaType: intl.formatMessage( mediaType === 'movie' ? messages.movie : messages.tvshow ), + mediaServerName: + publicRuntimeConfig.JELLYFIN_TYPE == 'emby' + ? 'Emby' + : settings.currentSettings.mediaServerType === + MediaServerType.PLEX + ? 'Plex' + : 'Jellyfin', })}