From b8ed49067c5b4bef6ad4353abfa9324ada9e2633 Mon Sep 17 00:00:00 2001 From: dd060606 Date: Thu, 16 Nov 2023 18:48:26 +0100 Subject: [PATCH 1/2] feat(manageslideover): button to delete a media from Radarr/Sonarr --- overseerr-api.yml | 17 +++ server/api/servarr/radarr.ts | 14 ++ server/api/servarr/sonarr.ts | 15 ++ server/routes/media.ts | 97 ++++++++++++ src/components/ManageSlideOver/index.tsx | 180 ++++++++++++++++++----- 5 files changed, 290 insertions(+), 33 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index 6e8f5896..92bc90be 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -5732,6 +5732,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 file /media/{mediaId}/{status}: post: summary: Update media status diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts index 1637a8d8..49c722fc 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -213,6 +213,20 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { ); } } + public async removeMovie(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 6cda2a49..11241364 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -316,6 +316,21 @@ class SonarrAPI extends ServarrBase<{ } } + public async removeSerie(serieId: number): Promise { + try { + const { id, title } = await this.getSeriesByTvdbId(serieId); + await this.axios.delete(`/series/${id}`, { + params: { + deleteFiles: true, + addImportExclusion: false, + }, + }); + logger.info(`[Sonarr] Removed serie ${title}`); + } catch (e) { + throw new Error(`[Sonarr] Failed to remove serie: ${e.message}`); + } + } + private buildSeasonList( seasons: number[], existingSeasons?: SonarrSeason[] 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 103781d1..bd1950e7 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -9,10 +9,19 @@ 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 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'; @@ -31,8 +40,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.', + manageModalDeleteMediaWarning: + '* This will delete this {mediaType} from {arr}, including the media file.', openarr: 'Open in {arr}', + deletearr: 'Remove from {arr}', openarr4k: 'Open in 4K {arr}', + deletearr4k: 'Remove from 4K {arr}', downloadstatus: 'Downloads', markavailable: 'Mark as Available', mark4kavailable: 'Mark as Available in 4K', @@ -85,6 +98,13 @@ const ManageSlideOver = ({ : null ); + const { data: radarrData } = useSWR( + '/api/v1/settings/radarr' + ); + const { data: sonarrData } = useSWR( + '/api/v1/settings/sonarr' + ); + const deleteMedia = async () => { if (data.mediaInfo) { await axios.delete(`/api/v1/media/${data.mediaInfo.id}`); @@ -92,6 +112,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`, { @@ -123,7 +172,6 @@ const ManageSlideOver = ({ ); }; - return ( )} {data.mediaInfo?.serviceUrl && ( - - - + <> + + + + {isDefaultService() && ( +
+ deleteMediaFile()} + confirmText={intl.formatMessage( + globalMessages.areyousure + )} + className="w-full" + > + + + {intl.formatMessage(messages.deletearr, { + arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + })} + + +
+ {intl.formatMessage( + messages.manageModalDeleteMediaWarning, + { + mediaType: intl.formatMessage( + mediaType === 'movie' + ? messages.movie + : messages.tvshow + ), + arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + } + )} +
+
+ )} + )} @@ -443,21 +524,54 @@ const ManageSlideOver = ({ )} {data?.mediaInfo?.serviceUrl4k && ( - - - + <> + + + + {isDefaultService() && ( +
+ deleteMediaFile()} + confirmText={intl.formatMessage( + globalMessages.areyousure + )} + className="w-full" + > + + + {intl.formatMessage(messages.deletearr4k, { + arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + })} + + +
+ {intl.formatMessage( + messages.manageModalDeleteMediaWarning, + { + mediaType: intl.formatMessage( + mediaType === 'movie' + ? messages.movie + : messages.tvshow + ), + arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + } + )} +
+
+ )} + )} From 4f1b31c6084c0c49213937f52542b374a6dd80af Mon Sep 17 00:00:00 2001 From: dd060606 Date: Sat, 18 Nov 2023 12:39:54 +0100 Subject: [PATCH 2/2] feat(i18n): add French and English translations for this feature --- src/i18n/locale/en.json | 3 +++ src/i18n/locale/fr.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 10165c9e..a8f5dfc9 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -228,10 +228,13 @@ "components.Login.validationemailrequired": "You must provide a valid email address", "components.Login.validationpasswordrequired": "You must provide a password", "components.ManageSlideOver.alltime": "All Time", + "components.ManageSlideOver.deletearr": "Remove from {arr}", + "components.ManageSlideOver.deletearr4k": "Remove from 4K {arr}", "components.ManageSlideOver.downloadstatus": "Downloads", "components.ManageSlideOver.manageModalAdvanced": "Advanced", "components.ManageSlideOver.manageModalClearMedia": "Clear Data", "components.ManageSlideOver.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.", + "components.ManageSlideOver.manageModalDeleteMediaWarning": "* This will delete this {mediaType} from {arr}, including the media file.", "components.ManageSlideOver.manageModalIssues": "Open Issues", "components.ManageSlideOver.manageModalMedia": "Media", "components.ManageSlideOver.manageModalMedia4k": "4K Media", diff --git a/src/i18n/locale/fr.json b/src/i18n/locale/fr.json index 05a8cf36..7f138973 100644 --- a/src/i18n/locale/fr.json +++ b/src/i18n/locale/fr.json @@ -992,6 +992,9 @@ "components.ManageSlideOver.plays": "{playCount, number} {playCount, plural, one {lecture} other {lectures}}", "components.Settings.Notifications.NotificationsPushbullet.channelTag": "Étiquette de canal", "components.ManageSlideOver.alltime": "Tout le temps", + "components.ManageSlideOver.deletearr": "Supprimer de {arr}", + "components.ManageSlideOver.deletearr4k": "Supprimer de {arr} 4K", + "components.ManageSlideOver.manageModalDeleteMediaWarning": "* Cela va supprimer ce média de {arr}, ainsi que le fichier associé.", "components.ManageSlideOver.manageModalMedia": "Média(s)", "components.ManageSlideOver.markallseasonsavailable": "Marquer toutes les saisons comme disponibles", "components.Settings.validationUrlBaseTrailingSlash": "L'URL de base ne doit pas ce terminer par un slash",