Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions server/api/servarr/radarr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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(RadarrAPI.prototype, 'getMovieByTmdbId', async () => ({
id: 0,
title: 'Test Movie',
}));
const del = mock.method(getAxios(radarr), 'delete', async () => ({}));

await radarr.removeMovie(550);

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));
});
});

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',
});
});
});
31 changes: 20 additions & 11 deletions server/api/servarr/radarr.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logger from '@server/logger';
import type { AxiosResponse } from 'axios';
import ServarrBase from './base';

export interface RadarrMovieOptions {
Expand Down Expand Up @@ -93,26 +94,27 @@
};

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}`,
},
});

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
host
of this request depends on a
user-provided value
.
The
host
of this request depends on a
user-provided value
.

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 (
Expand Down Expand Up @@ -270,6 +272,12 @@
public removeMovie = async (movieId: number): Promise<void> => {
try {
const { id, title } = await this.getMovieByTmdbId(movieId);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!id) {
logger.info(`[Radarr] Movie not in library, nothing to remove`, {
tmdbId: movieId,
});
return;
}
await this.axios.delete(`/movie/${id}`, {
params: {
deleteFiles: true,
Expand All @@ -278,9 +286,10 @@
});
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) {
return;
}
throw e;
}
Comment thread
fallenbagel marked this conversation as resolved.
};

Expand Down
97 changes: 97 additions & 0 deletions server/api/servarr/sonarr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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(SonarrAPI.prototype, 'getSeriesByTvdbId', async () => ({
id: 0,
title: 'Test Series',
}));
const del = mock.method(getAxios(sonarr), 'delete', async () => ({}));

await sonarr.removeSeries(1234);

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));
});
});

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',
});
});
});
31 changes: 20 additions & 11 deletions server/api/servarr/sonarr.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logger from '@server/logger';
import type { AxiosResponse } from 'axios';
import ServarrBase from './base';

export interface SonarrSeason {
Expand Down Expand Up @@ -166,26 +167,27 @@
}

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}`,
},
});

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
host
of this request depends on a
user-provided value
.
The
host
of this request depends on a
user-provided value
.

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> {
Expand Down Expand Up @@ -413,6 +415,12 @@
public removeSeries = async (serieId: number): Promise<void> => {
try {
const { id, title } = await this.getSeriesByTvdbId(serieId);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!id) {
logger.info(`[Sonarr] Series not in library, nothing to remove`, {
tvdbId: serieId,
});
return;
}
await this.axios.delete(`/series/${id}`, {
params: {
deleteFiles: true,
Expand All @@ -421,9 +429,10 @@
});
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) {
return;
}
throw e;
}
Comment thread
fallenbagel marked this conversation as resolved.
};

Expand Down
10 changes: 7 additions & 3 deletions server/routes/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ mediaRoutes.delete(
mediaId: media.id,
}
);
return;
return res.status(204).send();
}

let service;
Expand Down Expand Up @@ -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' });
}
Comment thread
Copilot marked this conversation as resolved.
logger.error('Something went wrong deleting media file', {
label: 'Media',
mediaId: req.params.id,
message: e.message,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
next({ status: 404, message: 'Media not found' });
next({ status: 500, message: 'Failed to delete media file' });
}
}
);
Expand Down
Loading