diff --git a/overseerr-api.yml b/overseerr-api.yml index 7339aa06..1f78cbda 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -5876,6 +5876,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 1637a8d8..809c71ef 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 eca0208c..6999c6ba 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -321,6 +321,20 @@ class SonarrAPI extends ServarrBase<{ 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 8f93116c..60191e5d 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -1,4 +1,7 @@ +import RadarrAPI from '@server/api/servarr/radarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; 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'; @@ -168,6 +171,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: Number(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 22ee6c0b..ca204210 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -8,11 +8,20 @@ import useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import { Bars4Icon, ServerIcon } from '@heroicons/react/24/outline'; -import { CheckCircleIcon, DocumentMinusIcon } from '@heroicons/react/24/solid'; +import { + CheckCircleIcon, + DocumentMinusIcon, + TrashIcon, +} from '@heroicons/react/24/solid'; import { IssueStatus } from '@server/constants/issue'; -import { MediaRequestStatus, MediaStatus } 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'; @@ -32,8 +41,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 {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}', + 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', @@ -88,6 +101,12 @@ const ManageSlideOver = ({ ? `/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) { @@ -96,6 +115,35 @@ const ManageSlideOver = ({ } }; + 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 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`, { @@ -328,6 +376,40 @@ const ManageSlideOver = ({ )} + + {hasPermission(Permission.ADMIN) && + data?.mediaInfo?.serviceUrl && + isDefaultService() && ( +
+ 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', + } + )} +
+
+ )} )} @@ -433,21 +515,54 @@ const ManageSlideOver = ({ )} {data?.mediaInfo?.serviceUrl4k && ( - - - + <> + + + + {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', + } + )} +
+
+ )} + )}