Skip to content
Closed
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
191 changes: 191 additions & 0 deletions server/api/servarr/radarr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { afterEach, describe, it, mock } from 'node:test';

import type { AxiosInstance } from 'axios';

import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
import RadarrAPI from '@server/api/servarr/radarr';

function buildRadarr(): RadarrAPI {
Expand All @@ -13,6 +14,39 @@ function getAxios(radarr: RadarrAPI): AxiosInstance {
return (radarr as unknown as { axios: AxiosInstance }).axios;
}

function baseOptions(
overrides: Partial<RadarrMovieOptions> = {}
): RadarrMovieOptions {
return {
title: 'Test Movie',
qualityProfileId: 1,
minimumAvailability: 'released',
tags: [],
profileId: 1,
year: 1999,
rootFolderPath: '/movies',
tmdbId: 550,
monitored: true,
...overrides,
};
}

function getMergeTags(
radarr: RadarrAPI
): (
existing: number[],
incoming?: number[]
) => { tags: number[]; changed: boolean } {
return (
radarr as unknown as {
mergeTags: (
existing: number[],
incoming?: number[]
) => { tags: number[]; changed: boolean };
}
).mergeTags.bind(radarr);
}

describe('RadarrAPI removeMovie', () => {
afterEach(() => mock.restoreAll());

Expand Down Expand Up @@ -92,6 +126,163 @@ describe('RadarrAPI removeMovie', () => {
});
});

describe('RadarrAPI mergeTags', () => {
it('returns existing tags unchanged when no incoming tags are given', () => {
const radarr = buildRadarr();
const result = getMergeTags(radarr)([1, 2], undefined);
assert.deepStrictEqual(result, { tags: [1, 2], changed: false });
});

it('reports unchanged when incoming tags are already a subset of existing tags', () => {
const radarr = buildRadarr();
const result = getMergeTags(radarr)([1, 2, 3], [2]);
assert.strictEqual(result.changed, false);
assert.deepStrictEqual(result.tags, [1, 2, 3]);
});

it('merges in new tags and reports changed', () => {
const radarr = buildRadarr();
const result = getMergeTags(radarr)([1, 2], [2, 3]);
assert.strictEqual(result.changed, true);
assert.deepStrictEqual(result.tags, [1, 2, 3]);
});
});

describe('RadarrAPI addMovie', () => {
afterEach(() => mock.restoreAll());

it('merges the requester tag when the movie already has a file', async () => {
const radarr = buildRadarr();
mock.method(RadarrAPI.prototype, 'getMovieByTmdbId', async () => ({
id: 7,
title: 'Test Movie',
hasFile: true,
tags: [1, 2],
}));
const put = mock.method(
getAxios(radarr),
'put',
async (_url: string, body: unknown) => ({
data: { ...(body as Record<string, unknown>), id: 7 },
})
);

const result = await radarr.addMovie(baseOptions({ tags: [3] }));

assert.strictEqual(put.mock.callCount(), 1);
assert.deepStrictEqual(
(put.mock.calls[0].arguments[1] as { tags: number[] }).tags,
[1, 2, 3]
);
assert.strictEqual(result.id, 7);
});

it('skips the PUT when the movie already has a file and no new tags to add', async () => {
const radarr = buildRadarr();
mock.method(RadarrAPI.prototype, 'getMovieByTmdbId', async () => ({
id: 7,
title: 'Test Movie',
hasFile: true,
tags: [1, 2],
}));
const put = mock.method(getAxios(radarr), 'put', async () => ({
data: {},
}));

const result = await radarr.addMovie(baseOptions({ tags: [1] }));

assert.strictEqual(put.mock.callCount(), 0);
assert.strictEqual(result.id, 7);
});

it('merges the requester tag when the movie is monitored but not yet downloaded', async () => {
const radarr = buildRadarr();
mock.method(RadarrAPI.prototype, 'getMovieByTmdbId', async () => ({
id: 7,
title: 'Test Movie',
hasFile: false,
monitored: true,
tags: [1],
}));
const put = mock.method(
getAxios(radarr),
'put',
async (_url: string, body: unknown) => ({
data: { ...(body as Record<string, unknown>), id: 7, hasFile: false },
})
);
const search = mock.method(
RadarrAPI.prototype,
'searchMovie',
async () => undefined
);

await radarr.addMovie(baseOptions({ tags: [2], searchNow: false }));

assert.strictEqual(put.mock.callCount(), 1);
assert.deepStrictEqual(
(put.mock.calls[0].arguments[1] as { tags: number[] }).tags,
[1, 2]
);
assert.strictEqual(search.mock.callCount(), 0);
});

it('triggers a search after merging tags when searchNow is set and the movie has no file', async () => {
const radarr = buildRadarr();
mock.method(RadarrAPI.prototype, 'getMovieByTmdbId', async () => ({
id: 7,
title: 'Test Movie',
hasFile: false,
monitored: true,
tags: [1],
}));
mock.method(
getAxios(radarr),
'put',
async (_url: string, body: unknown) => ({
data: { ...(body as Record<string, unknown>), id: 7, hasFile: false },
})
);
const search = mock.method(
RadarrAPI.prototype,
'searchMovie',
async () => undefined
);

await radarr.addMovie(baseOptions({ tags: [2], searchNow: true }));

assert.strictEqual(search.mock.callCount(), 1);
assert.strictEqual(search.mock.calls[0].arguments[0], 7);
});

it('leaves an already-monitored movie with no new tags unchanged (regression guard)', async () => {
const radarr = buildRadarr();
mock.method(RadarrAPI.prototype, 'getMovieByTmdbId', async () => ({
id: 7,
title: 'Test Movie',
hasFile: false,
monitored: true,
tags: [1],
}));
const put = mock.method(getAxios(radarr), 'put', async () => ({
data: {},
}));
const search = mock.method(
RadarrAPI.prototype,
'searchMovie',
async () => undefined
);

const result = await radarr.addMovie(
baseOptions({ tags: [1], searchNow: false })
);

assert.strictEqual(put.mock.callCount(), 0);
assert.strictEqual(search.mock.callCount(), 0);
assert.strictEqual(result.id, 7);
});
});

describe('RadarrAPI getMovieByTmdbId', () => {
afterEach(() => mock.restoreAll());

Expand Down
82 changes: 68 additions & 14 deletions server/api/servarr/radarr.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logger from '@server/logger';
import type { AxiosResponse } from 'axios';
import { isEqual } from 'lodash';
import ServarrBase from './base';

export interface RadarrMovieOptions {
Expand Down Expand Up @@ -124,16 +125,36 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
const movie = await this.getMovieByTmdbId(options.tmdbId);

if (movie.hasFile) {
const { tags: mergedTags, changed } = this.mergeTags(
movie.tags,
options.tags
);

if (!changed) {
logger.info(
'Title already exists and is available. Skipping add and returning success',
{
label: 'Radarr',
movie,
}
);
return movie;
}

const response = await this.axios.put<RadarrMovie>(`/movie`, {
...movie,
tags: mergedTags,
});
logger.info(
'Title already exists and is available. Skipping add and returning success',
'Title already exists and is available. Merged requester tag.',
{
label: 'Radarr',
movie,
movieId: response.data.id,
movieTitle: response.data.title,
}
);
return movie;
return response.data;
}

// movie exists in Radarr but is neither downloaded nor monitored
if (movie.id && !movie.monitored) {
const response = await this.axios.put<RadarrMovie>(`/movie`, {
Expand Down Expand Up @@ -183,27 +204,48 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {

if (movie.id) {
// Movie exists and is already monitored
logger.info('Movie is already monitored in Radarr.', {
label: 'Radarr',
movieId: movie.id,
movieTitle: movie.title,
hasFile: movie.hasFile,
});
const { tags: mergedTags, changed: tagsChanged } = this.mergeTags(
movie.tags,
options.tags
);

let current = movie;
if (tagsChanged) {
const response = await this.axios.put<RadarrMovie>(`/movie`, {
...movie,
tags: mergedTags,
});
logger.info(
'Movie already exists in Radarr and merged requester tag.',
{
label: 'Radarr',
movieId: response.data.id,
movieTitle: response.data.title,
}
);
current = response.data;
} else {
logger.info('Movie is already monitored in Radarr.', {
label: 'Radarr',
movieId: movie.id,
movieTitle: movie.title,
hasFile: movie.hasFile,
});
}
// If searchNow is requested and movie doesn't have a file, trigger search
if (options.searchNow && !movie.hasFile) {
logger.info(
'Triggering search for existing monitored movie without file',
{
label: 'Radarr',
movieId: movie.id,
movieTitle: movie.title,
movieId: current.id,
movieTitle: current.title,
}
);
this.searchMovie(movie.id);
this.searchMovie(current.id);
}

return movie;
return current;
}

const response = await this.axios.post<RadarrMovie>(`/movie`, {
Expand Down Expand Up @@ -250,6 +292,18 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
}
};

private mergeTags(
existingTags: number[],
incomingTags?: number[]
): { tags: number[]; changed: boolean } {
if (!incomingTags) {
return { tags: existingTags, changed: false };
}
const merged = Array.from(new Set([...existingTags, ...incomingTags]));
const changed = !isEqual(new Set(merged), new Set(existingTags));
return { tags: merged, changed };
}

public async searchMovie(movieId: number): Promise<void> {
logger.info('Executing movie search command', {
label: 'Radarr API',
Expand Down
Loading