From 8e908408cd1aedc54a085f3254b6ccfe676912fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Wed, 12 Aug 2026 21:05:58 +0200 Subject: [PATCH] Add RadarrClient --- src/app/clients/radarr/RadarrClient.ts | 45 +++++++++++++ .../radarr/endpoints/AddMovieEndpoint.ts | 16 +++++ .../radarr/endpoints/GetExistingEndpoint.ts | 12 ++++ .../radarr/endpoints/LookupEndpoint.ts | 32 ++++++++++ test/app/clients/radarr/RadarrClient.test.ts | 64 +++++++++++++++++++ 5 files changed, 169 insertions(+) create mode 100644 src/app/clients/radarr/RadarrClient.ts create mode 100644 src/app/clients/radarr/endpoints/AddMovieEndpoint.ts create mode 100644 src/app/clients/radarr/endpoints/GetExistingEndpoint.ts create mode 100644 src/app/clients/radarr/endpoints/LookupEndpoint.ts create mode 100644 test/app/clients/radarr/RadarrClient.test.ts diff --git a/src/app/clients/radarr/RadarrClient.ts b/src/app/clients/radarr/RadarrClient.ts new file mode 100644 index 0000000..93f17e3 --- /dev/null +++ b/src/app/clients/radarr/RadarrClient.ts @@ -0,0 +1,45 @@ +import { ApiClient } from '../../../core/http/ApiClient'; +import { LookupEndpoint, type RadarrMovie } from './endpoints/LookupEndpoint'; +import { AddMovieEndpoint } from './endpoints/AddMovieEndpoint'; +import { GetExistingEndpoint } from './endpoints/GetExistingEndpoint'; + +export type { RadarrMovie } from './endpoints/LookupEndpoint'; + +export class RadarrClient extends ApiClient { + private readonly lookupEndpoint = new LookupEndpoint(); + private readonly addMovieEndpoint = new AddMovieEndpoint(); + private readonly getExistingEndpoint = new GetExistingEndpoint(); + + constructor( + baseUrl: string, + private readonly apiKey: string, + private readonly qualityProfileId: number, + private readonly rootFolder: string + ) { + super(baseUrl); + } + + protected buildHeaders(): Record { + return { ...super.buildHeaders(), 'X-Api-Key': this.apiKey }; + } + + async lookup(tmdbId: number): Promise { + const result = await this.request(this.lookupEndpoint, { tmdbId }); + return { + title: result.title, + tmdbId: result.tmdbId, + qualityProfileId: this.qualityProfileId, + rootFolderPath: this.rootFolder, + monitored: true, + addOptions: { searchForMovie: true }, + }; + } + + addMovie(movie: RadarrMovie): Promise { + return this.request(this.addMovieEndpoint, movie); + } + + getExisting(): Promise { + return this.request(this.getExistingEndpoint, undefined); + } +} diff --git a/src/app/clients/radarr/endpoints/AddMovieEndpoint.ts b/src/app/clients/radarr/endpoints/AddMovieEndpoint.ts new file mode 100644 index 0000000..389a31c --- /dev/null +++ b/src/app/clients/radarr/endpoints/AddMovieEndpoint.ts @@ -0,0 +1,16 @@ +import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint'; +import type { RadarrMovie } from './LookupEndpoint'; + +export class AddMovieEndpoint extends Endpoint { + readonly method: HttpMethod = 'POST'; + readonly acceptableStatuses = [400]; + path(): string { + return '/api/v3/movie'; + } + body(params: RadarrMovie): unknown { + return params; + } + parseResponse(): void { + return undefined; + } +} diff --git a/src/app/clients/radarr/endpoints/GetExistingEndpoint.ts b/src/app/clients/radarr/endpoints/GetExistingEndpoint.ts new file mode 100644 index 0000000..20a5028 --- /dev/null +++ b/src/app/clients/radarr/endpoints/GetExistingEndpoint.ts @@ -0,0 +1,12 @@ +import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint'; +import type { RadarrMovie } from './LookupEndpoint'; + +export class GetExistingEndpoint extends Endpoint { + readonly method: HttpMethod = 'GET'; + path(): string { + return '/api/v3/movie'; + } + parseResponse(json: unknown): RadarrMovie[] { + return json as RadarrMovie[]; + } +} diff --git a/src/app/clients/radarr/endpoints/LookupEndpoint.ts b/src/app/clients/radarr/endpoints/LookupEndpoint.ts new file mode 100644 index 0000000..e8699ab --- /dev/null +++ b/src/app/clients/radarr/endpoints/LookupEndpoint.ts @@ -0,0 +1,32 @@ +import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint'; + +export interface RadarrMovie { + title: string; + tmdbId: number; + qualityProfileId: number; + rootFolderPath: string; + monitored: boolean; + addOptions: { searchForMovie: boolean }; +} + +interface RadarrLookupResult { + title: string; + tmdbId: number; +} + +export interface LookupParams { + tmdbId: number; +} + +export class LookupEndpoint extends Endpoint { + readonly method: HttpMethod = 'GET'; + path(): string { + return '/api/v3/movie/lookup/tmdb'; + } + query(params: LookupParams): Record { + return { tmdbId: String(params.tmdbId) }; + } + parseResponse(json: unknown): RadarrLookupResult { + return json as RadarrLookupResult; + } +} diff --git a/test/app/clients/radarr/RadarrClient.test.ts b/test/app/clients/radarr/RadarrClient.test.ts new file mode 100644 index 0000000..6821067 --- /dev/null +++ b/test/app/clients/radarr/RadarrClient.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RadarrClient } from '../../../../src/app/clients/radarr/RadarrClient'; + +describe('RadarrClient', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('lookup resolves TMDb metadata and fills in client-configured fields', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ title: 'Dune', tmdbId: 438631 }), + }); + + const client = new RadarrClient('http://radarr', 'radarr-key', 4, '/movies'); + const movie = await client.lookup(438631); + + expect(movie).toEqual({ + title: 'Dune', + tmdbId: 438631, + qualityProfileId: 4, + rootFolderPath: '/movies', + monitored: true, + addOptions: { searchForMovie: true }, + }); + expect(fetchMock.mock.calls[0][1].headers['X-Api-Key']).toBe('radarr-key'); + }); + + it('addMovie treats HTTP 400 (already exists) as success', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 400, text: async () => '' }); + + const client = new RadarrClient('http://radarr', 'radarr-key', 4, '/movies'); + await expect( + client.addMovie({ + title: 'Dune', + tmdbId: 438631, + qualityProfileId: 4, + rootFolderPath: '/movies', + monitored: true, + addOptions: { searchForMovie: true }, + }) + ).resolves.toBeUndefined(); + }); + + it('getExisting returns the full Radarr library', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify([{ title: 'Dune', tmdbId: 438631 }]), + }); + + const client = new RadarrClient('http://radarr', 'radarr-key', 4, '/movies'); + const existing = await client.getExisting(); + expect(existing[0].tmdbId).toBe(438631); + }); +});