From 1cf0d541d634ef36070bb6f8ffc73797938a38c3 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 3 Apr 2026 21:05:21 -0500 Subject: [PATCH] Add Lidarr/Readarr backend support - Add MUSIC and BOOK to MediaType enum - Add permission flags for music/book requests - Create Lidarr API adapter (artist/album search, add, remove) - Create Readarr API adapter (book/author search, add, remove) - Add Lidarr/Readarr settings interfaces and routes - Add music and book API routes for search/detail - Register all new routes in main router and settings router --- server/api/servarr/lidarr.ts | 271 ++++++++++++++++++++++++++++++ server/api/servarr/readarr.ts | 225 +++++++++++++++++++++++++ server/constants/media.ts | 2 + server/lib/permissions.ts | 4 + server/lib/settings/index.ts | 34 ++++ server/routes/book.ts | 94 +++++++++++ server/routes/index.ts | 4 + server/routes/music.ts | 135 +++++++++++++++ server/routes/settings/index.ts | 4 + server/routes/settings/lidarr.ts | 106 ++++++++++++ server/routes/settings/readarr.ts | 106 ++++++++++++ 11 files changed, 985 insertions(+) create mode 100644 server/api/servarr/lidarr.ts create mode 100644 server/api/servarr/readarr.ts create mode 100644 server/routes/book.ts create mode 100644 server/routes/music.ts create mode 100644 server/routes/settings/lidarr.ts create mode 100644 server/routes/settings/readarr.ts diff --git a/server/api/servarr/lidarr.ts b/server/api/servarr/lidarr.ts new file mode 100644 index 00000000..3310fe5f --- /dev/null +++ b/server/api/servarr/lidarr.ts @@ -0,0 +1,271 @@ +import logger from '@server/logger'; +import ServarrBase from './base'; + +export interface LidarrArtistOptions { + artistName: string; + qualityProfileId: number; + metadataProfileId: number; + tags: number[]; + rootFolderPath: string; + foreignArtistId: string; // MusicBrainz ID + monitored?: boolean; + searchNow?: boolean; +} + +export interface LidarrAlbumOptions { + foreignAlbumId: string; // MusicBrainz Album ID + monitored?: boolean; + searchNow?: boolean; +} + +export interface LidarrArtist { + id: number; + artistName: string; + foreignArtistId: string; + monitored: boolean; + path: string; + qualityProfileId: number; + metadataProfileId: number; + rootFolderPath: string; + tags: number[]; + added: string; + status: string; + ended: boolean; + artistType: string; + disambiguation: string; + images: { + coverType: string; + url: string; + remoteUrl: string; + }[]; + genres: string[]; + statistics?: { + albumCount: number; + trackFileCount: number; + trackCount: number; + totalTrackCount: number; + sizeOnDisk: number; + percentOfTracks: number; + }; +} + +export interface LidarrAlbum { + id: number; + title: string; + foreignAlbumId: string; + artistId: number; + monitored: boolean; + albumType: string; + duration: number; + releaseDate: string; + genres: string[]; + images: { + coverType: string; + url: string; + remoteUrl: string; + }[]; + artist: LidarrArtist; + statistics?: { + trackFileCount: number; + trackCount: number; + totalTrackCount: number; + sizeOnDisk: number; + percentOfTracks: number; + }; +} + +export interface MetadataProfile { + id: number; + name: string; +} + +class LidarrAPI extends ServarrBase<{ artistId: number }> { + constructor({ url, apiKey }: { url: string; apiKey: string }) { + super({ url, apiKey, cacheName: 'lidarr', apiName: 'Lidarr' }); + } + + public getArtists = async (): Promise => { + try { + const response = await this.axios.get('/artist'); + return response.data; + } catch (e) { + throw new Error(`[Lidarr] Failed to retrieve artists: ${e.message}`, { + cause: e, + }); + } + }; + + public getArtist = async ({ id }: { id: number }): Promise => { + try { + const response = await this.axios.get(`/artist/${id}`); + return response.data; + } catch (e) { + throw new Error(`[Lidarr] Failed to retrieve artist: ${e.message}`, { + cause: e, + }); + } + }; + + public async getArtistByMbId(mbId: string): Promise { + try { + const artists = await this.getArtists(); + return artists.find((a) => a.foreignArtistId === mbId) || null; + } catch (e) { + logger.error('Error retrieving artist by MusicBrainz ID', { + label: 'Lidarr API', + errorMessage: e.message, + mbId, + }); + return null; + } + } + + public searchArtist = async (term: string): Promise => { + try { + const response = await this.axios.get( + '/artist/lookup', + { params: { term } } + ); + return response.data; + } catch (e) { + throw new Error(`[Lidarr] Failed to search artists: ${e.message}`, { + cause: e, + }); + } + }; + + public searchAlbum = async (term: string): Promise => { + try { + const response = await this.axios.get('/album/lookup', { + params: { term }, + }); + return response.data; + } catch (e) { + throw new Error(`[Lidarr] Failed to search albums: ${e.message}`, { + cause: e, + }); + } + }; + + public getAlbums = async (artistId: number): Promise => { + try { + const response = await this.axios.get('/album', { + params: { artistId }, + }); + return response.data; + } catch (e) { + throw new Error(`[Lidarr] Failed to retrieve albums: ${e.message}`, { + cause: e, + }); + } + }; + + public getAlbum = async ({ id }: { id: number }): Promise => { + try { + const response = await this.axios.get(`/album/${id}`); + return response.data; + } catch (e) { + throw new Error(`[Lidarr] Failed to retrieve album: ${e.message}`, { + cause: e, + }); + } + }; + + public addArtist = async ( + options: LidarrArtistOptions + ): Promise => { + try { + // Check if artist already exists + const existing = await this.getArtistByMbId(options.foreignArtistId); + + if (existing) { + logger.info('Artist already exists in Lidarr.', { + label: 'Lidarr', + artistId: existing.id, + artistName: existing.artistName, + }); + return existing; + } + + // Look up artist details + const lookupResults = await this.searchArtist( + `lidarr:${options.foreignArtistId}` + ); + const lookupArtist = lookupResults[0]; + + const response = await this.axios.post('/artist', { + ...lookupArtist, + artistName: options.artistName, + qualityProfileId: options.qualityProfileId, + metadataProfileId: options.metadataProfileId, + rootFolderPath: options.rootFolderPath, + monitored: options.monitored ?? true, + tags: options.tags, + addOptions: { + monitor: 'all', + searchForMissingAlbums: options.searchNow ?? true, + }, + }); + + if (response.data.id) { + logger.info('Lidarr accepted request', { + label: 'Lidarr', + artistId: response.data.id, + artistName: response.data.artistName, + }); + } + return response.data; + } catch (e) { + logger.error('Failed to add artist to Lidarr', { + label: 'Lidarr', + errorMessage: e.message, + options, + }); + throw new Error('Failed to add artist to Lidarr', { cause: e }); + } + }; + + public async searchArtistCommand(artistId: number): Promise { + logger.info('Executing artist search command', { + label: 'Lidarr API', + artistId, + }); + try { + await this.runCommand('ArtistSearch', { artistId }); + } catch (e) { + logger.error('Something went wrong executing Lidarr artist search.', { + label: 'Lidarr API', + errorMessage: e.message, + artistId, + }); + } + } + + public getMetadataProfiles = async (): Promise => { + try { + const response = + await this.axios.get('/metadataprofile'); + return response.data; + } catch (e) { + throw new Error( + `[Lidarr] Failed to retrieve metadata profiles: ${e.message}`, + { cause: e } + ); + } + }; + + public removeArtist = async (artistId: number): Promise => { + try { + await this.axios.delete(`/artist/${artistId}`, { + params: { deleteFiles: true, addImportListExclusion: false }, + }); + logger.info(`[Lidarr] Removed artist ${artistId}`); + } catch (e) { + throw new Error(`[Lidarr] Failed to remove artist: ${e.message}`, { + cause: e, + }); + } + }; +} + +export default LidarrAPI; diff --git a/server/api/servarr/readarr.ts b/server/api/servarr/readarr.ts new file mode 100644 index 00000000..fc7b4241 --- /dev/null +++ b/server/api/servarr/readarr.ts @@ -0,0 +1,225 @@ +import logger from '@server/logger'; +import ServarrBase from './base'; + +export interface ReadarrBookOptions { + title: string; + qualityProfileId: number; + metadataProfileId: number; + tags: number[]; + rootFolderPath: string; + foreignBookId: string; // GoodReads/Edition ID + authorId?: number; + monitored?: boolean; + searchNow?: boolean; +} + +export interface ReadarrAuthor { + id: number; + authorName: string; + foreignAuthorId: string; + monitored: boolean; + path: string; + qualityProfileId: number; + metadataProfileId: number; + rootFolderPath: string; + tags: number[]; + added: string; + status: string; + ended: boolean; + images: { + coverType: string; + url: string; + remoteUrl: string; + }[]; + genres: string[]; + statistics?: { + bookFileCount: number; + bookCount: number; + totalBookCount: number; + sizeOnDisk: number; + percentOfBooks: number; + }; +} + +export interface ReadarrBook { + id: number; + title: string; + foreignBookId: string; + authorId: number; + monitored: boolean; + releaseDate: string; + genres: string[]; + images: { + coverType: string; + url: string; + remoteUrl: string; + }[]; + author: ReadarrAuthor; + overview: string; + pageCount: number; + statistics?: { + bookFileCount: number; + sizeOnDisk: number; + }; +} + +export interface ReadarrMetadataProfile { + id: number; + name: string; +} + +class ReadarrAPI extends ServarrBase<{ authorId: number }> { + constructor({ url, apiKey }: { url: string; apiKey: string }) { + super({ url, apiKey, cacheName: 'readarr', apiName: 'Readarr' }); + } + + public getAuthors = async (): Promise => { + try { + const response = await this.axios.get('/author'); + return response.data; + } catch (e) { + throw new Error(`[Readarr] Failed to retrieve authors: ${e.message}`, { + cause: e, + }); + } + }; + + public getAuthor = async ({ + id, + }: { + id: number; + }): Promise => { + try { + const response = await this.axios.get(`/author/${id}`); + return response.data; + } catch (e) { + throw new Error(`[Readarr] Failed to retrieve author: ${e.message}`, { + cause: e, + }); + } + }; + + public searchBook = async (term: string): Promise => { + try { + const response = await this.axios.get('/book/lookup', { + params: { term }, + }); + return response.data; + } catch (e) { + throw new Error(`[Readarr] Failed to search books: ${e.message}`, { + cause: e, + }); + } + }; + + public searchAuthor = async (term: string): Promise => { + try { + const response = await this.axios.get( + '/author/lookup', + { params: { term } } + ); + return response.data; + } catch (e) { + throw new Error(`[Readarr] Failed to search authors: ${e.message}`, { + cause: e, + }); + } + }; + + public getBooks = async (authorId: number): Promise => { + try { + const response = await this.axios.get('/book', { + params: { authorId }, + }); + return response.data; + } catch (e) { + throw new Error(`[Readarr] Failed to retrieve books: ${e.message}`, { + cause: e, + }); + } + }; + + public getBook = async ({ id }: { id: number }): Promise => { + try { + const response = await this.axios.get(`/book/${id}`); + return response.data; + } catch (e) { + throw new Error(`[Readarr] Failed to retrieve book: ${e.message}`, { + cause: e, + }); + } + }; + + public addBook = async ( + options: ReadarrBookOptions + ): Promise => { + try { + const lookupResults = await this.searchBook( + `readarr:${options.foreignBookId}` + ); + const lookupBook = lookupResults[0]; + + if (!lookupBook) { + throw new Error('Book not found in lookup'); + } + + const response = await this.axios.post('/book', { + ...lookupBook, + qualityProfileId: options.qualityProfileId, + metadataProfileId: options.metadataProfileId, + rootFolderPath: options.rootFolderPath, + monitored: options.monitored ?? true, + tags: options.tags, + addOptions: { + searchForNewBook: options.searchNow ?? true, + }, + }); + + if (response.data.id) { + logger.info('Readarr accepted request', { + label: 'Readarr', + bookId: response.data.id, + title: response.data.title, + }); + } + return response.data; + } catch (e) { + logger.error('Failed to add book to Readarr', { + label: 'Readarr', + errorMessage: e.message, + options, + }); + throw new Error('Failed to add book to Readarr', { cause: e }); + } + }; + + public getMetadataProfiles = async (): Promise< + ReadarrMetadataProfile[] + > => { + try { + const response = + await this.axios.get('/metadataprofile'); + return response.data; + } catch (e) { + throw new Error( + `[Readarr] Failed to retrieve metadata profiles: ${e.message}`, + { cause: e } + ); + } + }; + + public removeBook = async (bookId: number): Promise => { + try { + await this.axios.delete(`/book/${bookId}`, { + params: { deleteFiles: true, addImportListExclusion: false }, + }); + logger.info(`[Readarr] Removed book ${bookId}`); + } catch (e) { + throw new Error(`[Readarr] Failed to remove book: ${e.message}`, { + cause: e, + }); + } + }; +} + +export default ReadarrAPI; diff --git a/server/constants/media.ts b/server/constants/media.ts index 170109fb..6e33f5ae 100644 --- a/server/constants/media.ts +++ b/server/constants/media.ts @@ -9,6 +9,8 @@ export enum MediaRequestStatus { export enum MediaType { MOVIE = 'movie', TV = 'tv', + MUSIC = 'music', + BOOK = 'book', } export enum MediaStatus { diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index edc9f7e1..566a23ec 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -29,6 +29,10 @@ export enum Permission { WATCHLIST_VIEW = 134217728, MANAGE_BLOCKLIST = 268435456, VIEW_BLOCKLIST = 1073741824, + REQUEST_MUSIC = 536870912, + AUTO_APPROVE_MUSIC = 2147483648, + REQUEST_BOOK = 4294967296, + AUTO_APPROVE_BOOK = 8589934592, } export interface PermissionCheckOptions { diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 779413ca..bb1cdaea 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -102,6 +102,16 @@ export interface SonarrSettings extends DVRSettings { monitorNewItems: 'all' | 'none'; } +export interface LidarrSettings extends DVRSettings { + activeMetadataProfileId: number; + activeMetadataProfileName: string; +} + +export interface ReadarrSettings extends DVRSettings { + activeMetadataProfileId: number; + activeMetadataProfileName: string; +} + interface Quota { quotaLimit?: number; quotaDays?: number; @@ -137,6 +147,8 @@ export interface MainSettings { defaultQuotas: { movie: Quota; tv: Quota; + music: Quota; + book: Quota; }; hideAvailable: boolean; hideBlocklisted: boolean; @@ -368,6 +380,8 @@ export interface AllSettings { tautulli: TautulliSettings; radarr: RadarrSettings[]; sonarr: SonarrSettings[]; + lidarr: LidarrSettings[]; + readarr: ReadarrSettings[]; public: PublicSettings; notifications: NotificationSettings; jobs: Record; @@ -398,6 +412,8 @@ class Settings { defaultQuotas: { movie: {}, tv: {}, + music: {}, + book: {}, }, hideAvailable: false, hideBlocklisted: false, @@ -441,6 +457,8 @@ class Settings { }, radarr: [], sonarr: [], + lidarr: [], + readarr: [], public: { initialized: false, }, @@ -673,6 +691,22 @@ class Settings { this.data.sonarr = data; } + get lidarr(): LidarrSettings[] { + return this.data.lidarr; + } + + set lidarr(data: LidarrSettings[]) { + this.data.lidarr = data; + } + + get readarr(): ReadarrSettings[] { + return this.data.readarr; + } + + set readarr(data: ReadarrSettings[]) { + this.data.readarr = data; + } + get public(): PublicSettings { return this.data.public; } diff --git a/server/routes/book.ts b/server/routes/book.ts new file mode 100644 index 00000000..c7c7e4fc --- /dev/null +++ b/server/routes/book.ts @@ -0,0 +1,94 @@ +import ReadarrAPI from '@server/api/servarr/readarr'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { Router } from 'express'; + +const bookRoutes = Router(); + +bookRoutes.get('/search', async (req, res, next) => { + const { query } = req.query; + + if (!query || typeof query !== 'string') { + return res.status(400).json({ error: 'Query parameter is required' }); + } + + try { + const settings = getSettings(); + const readarrSettings = settings.readarr.find((r) => r.isDefault); + + if (!readarrSettings) { + return res.status(404).json({ error: 'No default Readarr server configured' }); + } + + const readarr = new ReadarrAPI({ + apiKey: readarrSettings.apiKey, + url: ReadarrAPI.buildUrl(readarrSettings, '/api/v1'), + }); + + const books = await readarr.searchBook(query); + + const results = books.slice(0, 20).map((book) => ({ + id: book.foreignBookId, + mediaType: 'book', + title: book.title, + overview: book.overview, + releaseDate: book.releaseDate, + images: book.images, + genres: book.genres, + pageCount: book.pageCount, + author: book.author ? { + id: book.author.foreignAuthorId, + name: book.author.authorName, + } : null, + foreignBookId: book.foreignBookId, + })); + + return res.status(200).json({ + results, + totalResults: results.length, + }); + } catch (e) { + logger.error('Failed to search books', { + label: 'Book API', + message: e.message, + }); + next({ status: 500, message: 'Failed to search books' }); + } +}); + +bookRoutes.get('/author/search', async (req, res, next) => { + const { query } = req.query; + + if (!query || typeof query !== 'string') { + return res.status(400).json({ error: 'Query parameter is required' }); + } + + try { + const settings = getSettings(); + const readarrSettings = settings.readarr.find((r) => r.isDefault); + + if (!readarrSettings) { + return res.status(404).json({ error: 'No default Readarr server configured' }); + } + + const readarr = new ReadarrAPI({ + apiKey: readarrSettings.apiKey, + url: ReadarrAPI.buildUrl(readarrSettings, '/api/v1'), + }); + + const authors = await readarr.searchAuthor(query); + + return res.status(200).json({ + results: authors.slice(0, 20), + totalResults: authors.length, + }); + } catch (e) { + logger.error('Failed to search authors', { + label: 'Book API', + message: e.message, + }); + next({ status: 500, message: 'Failed to search authors' }); + } +}); + +export default bookRoutes; diff --git a/server/routes/index.ts b/server/routes/index.ts index f701acf9..4266a90c 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -30,12 +30,14 @@ import { isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; import authRoutes from './auth'; import blocklistRoutes from './blocklist'; +import bookRoutes from './book'; import collectionRoutes from './collection'; import discoverRoutes, { createTmdbWithRegionLanguage } from './discover'; import issueRoutes from './issue'; import issueCommentRoutes from './issueComment'; import mediaRoutes from './media'; import movieRoutes from './movie'; +import musicRoutes from './music'; import personRoutes from './person'; import requestRoutes from './request'; import searchRoutes from './search'; @@ -165,6 +167,8 @@ router.use( ); router.use('/movie', isAuthenticated(), movieRoutes); router.use('/tv', isAuthenticated(), tvRoutes); +router.use('/music', isAuthenticated(), musicRoutes); +router.use('/book', isAuthenticated(), bookRoutes); router.use('/media', isAuthenticated(), mediaRoutes); router.use('/person', isAuthenticated(), personRoutes); router.use('/collection', isAuthenticated(), collectionRoutes); diff --git a/server/routes/music.ts b/server/routes/music.ts new file mode 100644 index 00000000..dc92d058 --- /dev/null +++ b/server/routes/music.ts @@ -0,0 +1,135 @@ +import LidarrAPI from '@server/api/servarr/lidarr'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { Router } from 'express'; + +const musicRoutes = Router(); + +musicRoutes.get('/search', async (req, res, next) => { + const { query } = req.query; + + if (!query || typeof query !== 'string') { + return res.status(400).json({ error: 'Query parameter is required' }); + } + + try { + const settings = getSettings(); + const lidarrSettings = settings.lidarr.find((l) => l.isDefault); + + if (!lidarrSettings) { + return res.status(404).json({ error: 'No default Lidarr server configured' }); + } + + const lidarr = new LidarrAPI({ + apiKey: lidarrSettings.apiKey, + url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'), + }); + + const artists = await lidarr.searchArtist(query); + + const results = artists.slice(0, 20).map((artist) => ({ + id: artist.foreignArtistId, + mediaType: 'music', + name: artist.artistName, + artistType: artist.artistType, + disambiguation: artist.disambiguation, + status: artist.status, + images: artist.images, + genres: artist.genres, + foreignArtistId: artist.foreignArtistId, + statistics: artist.statistics, + inLibrary: !!artist.id && artist.id > 0, + })); + + return res.status(200).json({ + results, + totalResults: results.length, + }); + } catch (e) { + logger.error('Failed to search music', { + label: 'Music API', + message: e.message, + }); + next({ status: 500, message: 'Failed to search music' }); + } +}); + +musicRoutes.get('/artist/:mbId', async (req, res, next) => { + try { + const settings = getSettings(); + const lidarrSettings = settings.lidarr.find((l) => l.isDefault); + + if (!lidarrSettings) { + return res.status(404).json({ error: 'No default Lidarr server configured' }); + } + + const lidarr = new LidarrAPI({ + apiKey: lidarrSettings.apiKey, + url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'), + }); + + // Search by MusicBrainz ID + const artists = await lidarr.searchArtist(`lidarr:${req.params.mbId}`); + const artist = artists[0]; + + if (!artist) { + return res.status(404).json({ error: 'Artist not found' }); + } + + // Get albums if artist is in library + let albums: any[] = []; + const existingArtist = await lidarr.getArtistByMbId(req.params.mbId); + if (existingArtist) { + albums = await lidarr.getAlbums(existingArtist.id); + } + + return res.status(200).json({ + ...artist, + albums, + inLibrary: !!existingArtist, + }); + } catch (e) { + logger.error('Failed to get artist details', { + label: 'Music API', + message: e.message, + }); + next({ status: 500, message: 'Failed to get artist details' }); + } +}); + +musicRoutes.get('/album/search', async (req, res, next) => { + const { query } = req.query; + + if (!query || typeof query !== 'string') { + return res.status(400).json({ error: 'Query parameter is required' }); + } + + try { + const settings = getSettings(); + const lidarrSettings = settings.lidarr.find((l) => l.isDefault); + + if (!lidarrSettings) { + return res.status(404).json({ error: 'No default Lidarr server configured' }); + } + + const lidarr = new LidarrAPI({ + apiKey: lidarrSettings.apiKey, + url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'), + }); + + const albums = await lidarr.searchAlbum(query); + + return res.status(200).json({ + results: albums.slice(0, 20), + totalResults: albums.length, + }); + } catch (e) { + logger.error('Failed to search albums', { + label: 'Music API', + message: e.message, + }); + next({ status: 500, message: 'Failed to search albums' }); + } +}); + +export default musicRoutes; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 12b57465..2ec2db02 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -41,7 +41,9 @@ import semver from 'semver'; import { URL } from 'url'; import metadataRoutes from './metadata'; import notificationRoutes from './notifications'; +import lidarrRoutes from './lidarr'; import radarrRoutes from './radarr'; +import readarrRoutes from './readarr'; import sonarrRoutes from './sonarr'; const settingsRoutes = Router(); @@ -49,6 +51,8 @@ const settingsRoutes = Router(); settingsRoutes.use('/notifications', notificationRoutes); settingsRoutes.use('/radarr', radarrRoutes); settingsRoutes.use('/sonarr', sonarrRoutes); +settingsRoutes.use('/lidarr', lidarrRoutes); +settingsRoutes.use('/readarr', readarrRoutes); settingsRoutes.use('/discover', discoverSettingRoutes); settingsRoutes.use('/metadatas', metadataRoutes); diff --git a/server/routes/settings/lidarr.ts b/server/routes/settings/lidarr.ts new file mode 100644 index 00000000..7fc2036e --- /dev/null +++ b/server/routes/settings/lidarr.ts @@ -0,0 +1,106 @@ +import LidarrAPI from '@server/api/servarr/lidarr'; +import type { LidarrSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { Router } from 'express'; + +const lidarrRoutes = Router(); + +lidarrRoutes.get('/', (_req, res) => { + const settings = getSettings(); + res.status(200).json(settings.lidarr); +}); + +lidarrRoutes.post('/', async (req, res) => { + const settings = getSettings(); + const newLidarr = req.body as LidarrSettings; + const lastItem = settings.lidarr[settings.lidarr.length - 1]; + newLidarr.id = lastItem ? lastItem.id + 1 : 0; + + if (req.body.isDefault) { + settings.lidarr.forEach((instance) => { + instance.isDefault = false; + }); + } + + settings.lidarr = [...settings.lidarr, newLidarr]; + await settings.save(); + return res.status(201).json(newLidarr); +}); + +lidarrRoutes.post< + undefined, + Record, + LidarrSettings +>('/test', async (req, res, next) => { + try { + const lidarr = new LidarrAPI({ + apiKey: req.body.apiKey, + url: LidarrAPI.buildUrl(req.body, '/api/v1'), + }); + + const profiles = await lidarr.getProfiles(); + const folders = await lidarr.getRootFolders(); + const tags = await lidarr.getTags(); + const metadataProfiles = await lidarr.getMetadataProfiles(); + + return res.status(200).json({ + profiles, + rootFolders: folders.map((folder) => ({ + id: folder.id, + path: folder.path, + })), + tags, + metadataProfiles, + }); + } catch (e) { + logger.error('Failed to test Lidarr', { + label: 'Lidarr', + message: e.message, + }); + next({ status: 500, message: 'Failed to connect to Lidarr' }); + } +}); + +lidarrRoutes.put<{ id: string }>('/:id', async (req, res, next) => { + const settings = getSettings(); + const lidarrIndex = settings.lidarr.findIndex( + (r) => r.id === Number(req.params.id) + ); + + if (lidarrIndex === -1) { + return next({ status: 404, message: 'Lidarr server not found.' }); + } + + if (req.body.isDefault) { + settings.lidarr.forEach((instance) => { + instance.isDefault = false; + }); + } + + settings.lidarr[lidarrIndex] = { + ...settings.lidarr[lidarrIndex], + ...req.body, + id: Number(req.params.id), + } as LidarrSettings; + + await settings.save(); + return res.status(200).json(settings.lidarr[lidarrIndex]); +}); + +lidarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => { + const settings = getSettings(); + const lidarrIndex = settings.lidarr.findIndex( + (r) => r.id === Number(req.params.id) + ); + + if (lidarrIndex === -1) { + return next({ status: 404, message: 'Lidarr server not found.' }); + } + + const removed = settings.lidarr.splice(lidarrIndex, 1); + await settings.save(); + return res.status(200).json(removed[0]); +}); + +export default lidarrRoutes; diff --git a/server/routes/settings/readarr.ts b/server/routes/settings/readarr.ts new file mode 100644 index 00000000..bd0b0bca --- /dev/null +++ b/server/routes/settings/readarr.ts @@ -0,0 +1,106 @@ +import ReadarrAPI from '@server/api/servarr/readarr'; +import type { ReadarrSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { Router } from 'express'; + +const readarrRoutes = Router(); + +readarrRoutes.get('/', (_req, res) => { + const settings = getSettings(); + res.status(200).json(settings.readarr); +}); + +readarrRoutes.post('/', async (req, res) => { + const settings = getSettings(); + const newReadarr = req.body as ReadarrSettings; + const lastItem = settings.readarr[settings.readarr.length - 1]; + newReadarr.id = lastItem ? lastItem.id + 1 : 0; + + if (req.body.isDefault) { + settings.readarr.forEach((instance) => { + instance.isDefault = false; + }); + } + + settings.readarr = [...settings.readarr, newReadarr]; + await settings.save(); + return res.status(201).json(newReadarr); +}); + +readarrRoutes.post< + undefined, + Record, + ReadarrSettings +>('/test', async (req, res, next) => { + try { + const readarr = new ReadarrAPI({ + apiKey: req.body.apiKey, + url: ReadarrAPI.buildUrl(req.body, '/api/v1'), + }); + + const profiles = await readarr.getProfiles(); + const folders = await readarr.getRootFolders(); + const tags = await readarr.getTags(); + const metadataProfiles = await readarr.getMetadataProfiles(); + + return res.status(200).json({ + profiles, + rootFolders: folders.map((folder) => ({ + id: folder.id, + path: folder.path, + })), + tags, + metadataProfiles, + }); + } catch (e) { + logger.error('Failed to test Readarr', { + label: 'Readarr', + message: e.message, + }); + next({ status: 500, message: 'Failed to connect to Readarr' }); + } +}); + +readarrRoutes.put<{ id: string }>('/:id', async (req, res, next) => { + const settings = getSettings(); + const readarrIndex = settings.readarr.findIndex( + (r) => r.id === Number(req.params.id) + ); + + if (readarrIndex === -1) { + return next({ status: 404, message: 'Readarr server not found.' }); + } + + if (req.body.isDefault) { + settings.readarr.forEach((instance) => { + instance.isDefault = false; + }); + } + + settings.readarr[readarrIndex] = { + ...settings.readarr[readarrIndex], + ...req.body, + id: Number(req.params.id), + } as ReadarrSettings; + + await settings.save(); + return res.status(200).json(settings.readarr[readarrIndex]); +}); + +readarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => { + const settings = getSettings(); + const readarrIndex = settings.readarr.findIndex( + (r) => r.id === Number(req.params.id) + ); + + if (readarrIndex === -1) { + return next({ status: 404, message: 'Readarr server not found.' }); + } + + const removed = settings.readarr.splice(readarrIndex, 1); + await settings.save(); + return res.status(200).json(removed[0]); +}); + +export default readarrRoutes;