fix: handle already-removed media in radarr/sonarr delete (#3209)

This commit is contained in:
fallenbagel
2026-07-30 00:49:55 +05:00
committed by GitHub
parent 95e18ded1d
commit 8ea0c174ea
8 changed files with 353 additions and 46 deletions
+119
View File
@@ -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',
});
});
});
+27 -13
View File
@@ -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<RadarrMovie> {
let response: AxiosResponse<RadarrMovie[]>;
try {
const response = await this.axios.get<RadarrMovie[]>('/movie/lookup', {
response = await this.axios.get<RadarrMovie[]>('/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<void> => {
public removeMovie = async (tmdbId: number): Promise<void> => {
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;
}
};
+119
View File
@@ -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',
});
});
});
+27 -13
View File
@@ -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<SonarrSeries> {
let response: AxiosResponse<SonarrSeries[]>;
try {
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
response = await this.axios.get<SonarrSeries[]>('/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<SonarrSeries> {
@@ -410,9 +412,17 @@ class SonarrAPI extends ServarrBase<{
return newSeasons;
}
public removeSeries = async (serieId: number): Promise<void> => {
public removeSeries = async (tvdbId: number): Promise<void> => {
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;
}
};
+13 -9
View File
@@ -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' });
}
}
);
+29 -7
View File
@@ -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<MediaWatchDataResponse>(
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();
}
@@ -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();
}
};
+3
View File
@@ -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": "<strong>{playCount, number}</strong> {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}}",