diff --git a/server/api/servarr/radarr.test.ts b/server/api/servarr/radarr.test.ts new file mode 100644 index 000000000..f3aaed384 --- /dev/null +++ b/server/api/servarr/radarr.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it, mock } from 'node:test'; + +import type { AxiosInstance } from 'axios'; + +import RadarrAPI from '@server/api/servarr/radarr'; + +function buildRadarr(): RadarrAPI { + return new RadarrAPI({ url: 'http://localhost:7878/api/v3', apiKey: 'test' }); +} + +function getAxios(radarr: RadarrAPI): AxiosInstance { + return (radarr as unknown as { axios: AxiosInstance }).axios; +} + +describe('RadarrAPI removeMovie', () => { + afterEach(() => mock.restoreAll()); + + it('removes the movie when it exists in the library', async () => { + const radarr = buildRadarr(); + mock.method(RadarrAPI.prototype, 'getMovieByTmdbId', async () => ({ + id: 7, + title: 'Test Movie', + })); + const del = mock.method(getAxios(radarr), 'delete', async () => ({})); + + await radarr.removeMovie(550); + + assert.strictEqual(del.mock.callCount(), 1); + assert.strictEqual(del.mock.calls[0].arguments[0], '/movie/7'); + }); + + it('does nothing when the movie is not in the library', async () => { + const radarr = buildRadarr(); + mock.method(getAxios(radarr), 'get', async () => ({ + data: [{ id: 0, title: 'Fight Club' }], + })); + const del = mock.method(getAxios(radarr), 'delete', async () => ({})); + + await assert.doesNotReject(() => radarr.removeMovie(550)); + assert.strictEqual(del.mock.callCount(), 0); + }); + + it('rejects when the tmdbId is unknown to the lookup', async () => { + const radarr = buildRadarr(); + mock.method(getAxios(radarr), 'get', async () => ({ data: [] })); + const del = mock.method(getAxios(radarr), 'delete', async () => ({})); + + await assert.rejects(() => radarr.removeMovie(550), /Movie not found/); + assert.strictEqual(del.mock.callCount(), 0); + }); + + it('ignores a 404 when the movie was already removed in Radarr', async () => { + const radarr = buildRadarr(); + mock.method(RadarrAPI.prototype, 'getMovieByTmdbId', async () => ({ + id: 7, + title: 'Test Movie', + })); + mock.method(getAxios(radarr), 'delete', async () => { + throw { response: { status: 404 } }; + }); + + await assert.doesNotReject(() => radarr.removeMovie(550)); + }); + + it('rethrows errors other than 404', async () => { + const radarr = buildRadarr(); + mock.method(RadarrAPI.prototype, 'getMovieByTmdbId', async () => ({ + id: 7, + title: 'Test Movie', + })); + mock.method(getAxios(radarr), 'delete', async () => { + throw { response: { status: 500 } }; + }); + + await assert.rejects(() => radarr.removeMovie(550)); + }); + + it('rethrows a 404 from the lookup instead of treating it as removed', async () => { + const radarr = buildRadarr(); + mock.method(getAxios(radarr), 'get', async () => { + throw { response: { status: 404 } }; + }); + const del = mock.method(getAxios(radarr), 'delete', async () => ({})); + + await assert.rejects( + () => radarr.removeMovie(550), + (e: unknown) => + (e as { response?: { status?: number } }).response?.status === 404 + ); + assert.strictEqual(del.mock.callCount(), 0); + }); +}); + +describe('RadarrAPI getMovieByTmdbId', () => { + afterEach(() => mock.restoreAll()); + + it('rethrows a 401 from the lookup with the status intact', async () => { + const radarr = buildRadarr(); + mock.method(getAxios(radarr), 'get', async () => { + throw { response: { status: 401 } }; + }); + + await assert.rejects( + () => radarr.getMovieByTmdbId(550), + (e: unknown) => + (e as { response?: { status?: number } }).response?.status === 401 + ); + }); + + it('throws "Movie not found" when the lookup returns no results', async () => { + const radarr = buildRadarr(); + mock.method(getAxios(radarr), 'get', async () => ({ data: [] })); + + await assert.rejects(() => radarr.getMovieByTmdbId(550), { + message: 'Movie not found', + }); + }); +}); diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts index 1d35778c0..4a30dfdad 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -1,4 +1,5 @@ import logger from '@server/logger'; +import type { AxiosResponse } from 'axios'; import ServarrBase from './base'; export interface RadarrMovieOptions { @@ -93,26 +94,27 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { }; public async getMovieByTmdbId(id: number): Promise { + let response: AxiosResponse; try { - const response = await this.axios.get('/movie/lookup', { + 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', errorMessage: e.message, tmdbId: id, }); - throw new Error('Movie not found', { cause: e }); + throw e; } + + if (!response.data[0]) { + throw new Error('Movie not found'); + } + + return response.data[0]; } public addMovie = async ( @@ -267,9 +269,17 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { ); } } - public removeMovie = async (movieId: number): Promise => { + public removeMovie = async (tmdbId: number): Promise => { + const { id, title } = await this.getMovieByTmdbId(tmdbId); + + if (!id) { + logger.info(`[Radarr] Movie not in library, nothing to remove`, { + tmdbId, + }); + return; + } + try { - const { id, title } = await this.getMovieByTmdbId(movieId); await this.axios.delete(`/movie/${id}`, { params: { deleteFiles: true, @@ -278,9 +288,13 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { }); logger.info(`[Radarr] Removed movie ${title}`); } catch (e) { - throw new Error(`[Radarr] Failed to remove movie: ${e.message}`, { - cause: e, - }); + if (e?.response?.status === 404) { + logger.info(`[Radarr] Movie already removed from Radarr`, { + tmdbId, + }); + return; + } + throw e; } }; diff --git a/server/api/servarr/sonarr.test.ts b/server/api/servarr/sonarr.test.ts new file mode 100644 index 000000000..015b57e90 --- /dev/null +++ b/server/api/servarr/sonarr.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it, mock } from 'node:test'; + +import type { AxiosInstance } from 'axios'; + +import SonarrAPI from '@server/api/servarr/sonarr'; + +function buildSonarr(): SonarrAPI { + return new SonarrAPI({ url: 'http://localhost:8989/api/v3', apiKey: 'test' }); +} + +function getAxios(sonarr: SonarrAPI): AxiosInstance { + return (sonarr as unknown as { axios: AxiosInstance }).axios; +} + +describe('SonarrAPI removeSeries', () => { + afterEach(() => mock.restoreAll()); + + it('removes the series when it exists in the library', async () => { + const sonarr = buildSonarr(); + mock.method(SonarrAPI.prototype, 'getSeriesByTvdbId', async () => ({ + id: 9, + title: 'Test Series', + })); + const del = mock.method(getAxios(sonarr), 'delete', async () => ({})); + + await sonarr.removeSeries(1234); + + assert.strictEqual(del.mock.callCount(), 1); + assert.strictEqual(del.mock.calls[0].arguments[0], '/series/9'); + }); + + it('does nothing when the series is not in the library', async () => { + const sonarr = buildSonarr(); + mock.method(getAxios(sonarr), 'get', async () => ({ + data: [{ id: 0, title: 'Breaking Bad' }], + })); + const del = mock.method(getAxios(sonarr), 'delete', async () => ({})); + + await assert.doesNotReject(() => sonarr.removeSeries(1234)); + assert.strictEqual(del.mock.callCount(), 0); + }); + + it('rejects when the tvdbId is unknown to the lookup', async () => { + const sonarr = buildSonarr(); + mock.method(getAxios(sonarr), 'get', async () => ({ data: [] })); + const del = mock.method(getAxios(sonarr), 'delete', async () => ({})); + + await assert.rejects(() => sonarr.removeSeries(1234), /Series not found/); + assert.strictEqual(del.mock.callCount(), 0); + }); + + it('ignores a 404 when the series was already removed in Sonarr', async () => { + const sonarr = buildSonarr(); + mock.method(SonarrAPI.prototype, 'getSeriesByTvdbId', async () => ({ + id: 9, + title: 'Test Series', + })); + mock.method(getAxios(sonarr), 'delete', async () => { + throw { response: { status: 404 } }; + }); + + await assert.doesNotReject(() => sonarr.removeSeries(1234)); + }); + + it('rethrows errors other than 404', async () => { + const sonarr = buildSonarr(); + mock.method(SonarrAPI.prototype, 'getSeriesByTvdbId', async () => ({ + id: 9, + title: 'Test Series', + })); + mock.method(getAxios(sonarr), 'delete', async () => { + throw { response: { status: 500 } }; + }); + + await assert.rejects(() => sonarr.removeSeries(1234)); + }); + + it('rethrows a 404 from the lookup instead of treating it as removed', async () => { + const sonarr = buildSonarr(); + mock.method(getAxios(sonarr), 'get', async () => { + throw { response: { status: 404 } }; + }); + const del = mock.method(getAxios(sonarr), 'delete', async () => ({})); + + await assert.rejects( + () => sonarr.removeSeries(1234), + (e: unknown) => + (e as { response?: { status?: number } }).response?.status === 404 + ); + assert.strictEqual(del.mock.callCount(), 0); + }); +}); + +describe('SonarrAPI getSeriesByTvdbId', () => { + afterEach(() => mock.restoreAll()); + + it('rethrows a 401 from the lookup with the status intact', async () => { + const sonarr = buildSonarr(); + mock.method(getAxios(sonarr), 'get', async () => { + throw { response: { status: 401 } }; + }); + + await assert.rejects( + () => sonarr.getSeriesByTvdbId(1234), + (e: unknown) => + (e as { response?: { status?: number } }).response?.status === 401 + ); + }); + + it('throws "Series not found" when the lookup returns no results', async () => { + const sonarr = buildSonarr(); + mock.method(getAxios(sonarr), 'get', async () => ({ data: [] })); + + await assert.rejects(() => sonarr.getSeriesByTvdbId(1234), { + message: 'Series not found', + }); + }); +}); diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 33354e1cd..c5b4eec93 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -1,4 +1,5 @@ import logger from '@server/logger'; +import type { AxiosResponse } from 'axios'; import ServarrBase from './base'; export interface SonarrSeason { @@ -166,26 +167,27 @@ class SonarrAPI extends ServarrBase<{ } public async getSeriesByTvdbId(id: number): Promise { + let response: AxiosResponse; try { - const response = await this.axios.get('/series/lookup', { + response = await this.axios.get('/series/lookup', { params: { term: `tvdb:${id}`, }, }); - - if (!response.data[0]) { - throw new Error('Series not found'); - } - - return response.data[0]; } catch (e) { logger.error('Error retrieving series by tvdb ID', { label: 'Sonarr API', errorMessage: e.message, tvdbId: id, }); - throw new Error('Series not found', { cause: e }); + throw e; } + + if (!response.data[0]) { + throw new Error('Series not found'); + } + + return response.data[0]; } public async addSeries(options: AddSeriesOptions): Promise { @@ -410,9 +412,17 @@ class SonarrAPI extends ServarrBase<{ return newSeasons; } - public removeSeries = async (serieId: number): Promise => { + public removeSeries = async (tvdbId: number): Promise => { + const { id, title } = await this.getSeriesByTvdbId(tvdbId); + + if (!id) { + logger.info(`[Sonarr] Series not in library, nothing to remove`, { + tvdbId, + }); + return; + } + try { - const { id, title } = await this.getSeriesByTvdbId(serieId); await this.axios.delete(`/series/${id}`, { params: { deleteFiles: true, @@ -421,9 +431,13 @@ class SonarrAPI extends ServarrBase<{ }); logger.info(`[Sonarr] Removed series ${title}`); } catch (e) { - throw new Error(`[Sonarr] Failed to remove series: ${e.message}`, { - cause: e, - }); + if (e?.response?.status === 404) { + logger.info(`[Sonarr] Series already removed from Sonarr`, { + tvdbId, + }); + return; + } + throw e; } }; diff --git a/server/routes/media.ts b/server/routes/media.ts index dbbe34aea..1626de95b 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -243,18 +243,18 @@ mediaRoutes.delete( } 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?`, + const arrName = `${is4k ? '4K ' : ''}${isMovie ? 'Radarr' : 'Sonarr'}`; + logger.info( + `There is no default ${arrName} server configured. Did you set any of your ${arrName} servers as default?`, { label: 'Media Request', mediaId: media.id, } ); - return; + return next({ + status: 409, + message: `No ${arrName} server configured to delete media files`, + }); } let service; @@ -284,11 +284,15 @@ mediaRoutes.delete( return res.status(204).send(); } catch (e) { - logger.error('Something went wrong fetching media in delete request', { + if (e instanceof EntityNotFoundError) { + return next({ status: 404, message: 'Media not found' }); + } + logger.error('Something went wrong deleting media file', { label: 'Media', + mediaId: req.params.id, message: e.message, }); - next({ status: 404, message: 'Media not found' }); + next({ status: 500, message: 'Failed to delete media file' }); } } ); diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index 13e32d4ef..5be4d54c9 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -8,6 +8,7 @@ import DownloadBlock from '@app/components/DownloadBlock'; import IssueBlock from '@app/components/IssueBlock'; import RequestBlock from '@app/components/RequestBlock'; import useSettings from '@app/hooks/useSettings'; +import useToasts from '@app/hooks/useToasts'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; @@ -64,6 +65,8 @@ const messages = defineMessages('components.ManageSlideOver', { removearr: 'Remove from {arr}', openarr4k: 'Open in 4K {arr}', removearr4k: 'Remove from 4K {arr}', + clearmediadataerror: 'Something went wrong while clearing the media data.', + removemediaerror: 'Something went wrong while removing the media.', downloadstatus: 'Downloads', markavailable: 'Mark as Available', mark4kavailable: 'Mark as Available in 4K', @@ -109,6 +112,7 @@ const ManageSlideOver = ({ }: ManageSlideOverMovieProps | ManageSlideOverTvProps) => { const { user: currentUser, hasPermission } = useUser(); const intl = useIntl(); + const { addToast } = useToasts(); const settings = useSettings(); const { data: watchData } = useSWR( settings.currentSettings.mediaServerType === MediaServerType.PLEX && @@ -126,18 +130,36 @@ const ManageSlideOver = ({ const deleteMedia = async () => { if (data.mediaInfo) { - await axios.delete(`/api/v1/media/${data.mediaInfo.id}`); - revalidate(); - onClose(); + try { + await axios.delete(`/api/v1/media/${data.mediaInfo.id}`); + revalidate(); + onClose(); + } catch { + addToast(intl.formatMessage(messages.clearmediadataerror), { + appearance: 'error', + autoDismiss: true, + }); + } } }; const deleteMediaFile = async (is4k = false) => { if (data.mediaInfo) { - await axios.delete( - `/api/v1/media/${data.mediaInfo.id}/file?is4k=${is4k}` - ); - await axios.delete(`/api/v1/media/${data.mediaInfo.id}`); + try { + await axios.delete( + `/api/v1/media/${data.mediaInfo.id}/file?is4k=${is4k}` + ); + await axios.delete(`/api/v1/media/${data.mediaInfo.id}`); + } catch (e) { + if (!axios.isAxiosError(e) || e.response?.status !== 404) { + addToast(intl.formatMessage(messages.removemediaerror), { + appearance: 'error', + autoDismiss: true, + }); + revalidate(); + return; + } + } revalidate(); onClose(); } diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index db1f2f216..b3d309eee 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -47,6 +47,7 @@ const messages = defineMessages('components.RequestList.RequestItem', { tvdbid: 'TheTVDB ID', unknowntitle: 'Unknown Title', removearr: 'Remove from {arr}', + removemediaerror: 'Something went wrong while removing the media.', profileName: 'Profile', }); @@ -354,10 +355,21 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { const deleteMediaFile = async () => { if (request.media) { - await axios.delete( - `/api/v1/media/${request.media.id}/file?is4k=${request.is4k}` - ); - await axios.delete(`/api/v1/media/${request.media.id}`); + try { + await axios.delete( + `/api/v1/media/${request.media.id}/file?is4k=${request.is4k}` + ); + await axios.delete(`/api/v1/media/${request.media.id}`); + } catch (e) { + if (!axios.isAxiosError(e) || e.response?.status !== 404) { + addToast(intl.formatMessage(messages.removemediaerror), { + autoDismiss: true, + appearance: 'error', + }); + revalidateList(); + return; + } + } revalidateList(); } }; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index c456e21e5..d546f6782 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -305,6 +305,7 @@ "components.Login.validationservertyperequired": "Please select a server type", "components.Login.validationusernamerequired": "Username required", "components.ManageSlideOver.alltime": "All Time", + "components.ManageSlideOver.clearmediadataerror": "Something went wrong while clearing the media data.", "components.ManageSlideOver.downloadstatus": "Downloads", "components.ManageSlideOver.manageModalAdvanced": "Advanced", "components.ManageSlideOver.manageModalClearMedia": "Clear Data", @@ -329,6 +330,7 @@ "components.ManageSlideOver.plays": "{playCount, number} {playCount, plural, one {play} other {plays}}", "components.ManageSlideOver.removearr": "Remove from {arr}", "components.ManageSlideOver.removearr4k": "Remove from 4K {arr}", + "components.ManageSlideOver.removemediaerror": "Something went wrong while removing the media.", "components.ManageSlideOver.tvshow": "series", "components.MediaSlider.ShowMoreCard.seemore": "See More", "components.MetadataSelector.selectMetdataProvider": "Select a metadata provider", @@ -528,6 +530,7 @@ "components.RequestList.RequestItem.modifieduserdate": "{date} by {user}", "components.RequestList.RequestItem.profileName": "Profile", "components.RequestList.RequestItem.removearr": "Remove from {arr}", + "components.RequestList.RequestItem.removemediaerror": "Something went wrong while removing the media.", "components.RequestList.RequestItem.requested": "Requested", "components.RequestList.RequestItem.requesteddate": "Requested", "components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",