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
This commit is contained in:
root
2026-04-03 21:05:21 -05:00
parent dc40ca413c
commit 1cf0d541d6
11 changed files with 985 additions and 0 deletions

View File

@@ -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<LidarrArtist[]> => {
try {
const response = await this.axios.get<LidarrArtist[]>('/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<LidarrArtist> => {
try {
const response = await this.axios.get<LidarrArtist>(`/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<LidarrArtist | null> {
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<LidarrArtist[]> => {
try {
const response = await this.axios.get<LidarrArtist[]>(
'/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<LidarrAlbum[]> => {
try {
const response = await this.axios.get<LidarrAlbum[]>('/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<LidarrAlbum[]> => {
try {
const response = await this.axios.get<LidarrAlbum[]>('/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<LidarrAlbum> => {
try {
const response = await this.axios.get<LidarrAlbum>(`/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<LidarrArtist> => {
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<LidarrArtist>('/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<void> {
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<MetadataProfile[]> => {
try {
const response =
await this.axios.get<MetadataProfile[]>('/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<void> => {
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;

View File

@@ -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<ReadarrAuthor[]> => {
try {
const response = await this.axios.get<ReadarrAuthor[]>('/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<ReadarrAuthor> => {
try {
const response = await this.axios.get<ReadarrAuthor>(`/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<ReadarrBook[]> => {
try {
const response = await this.axios.get<ReadarrBook[]>('/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<ReadarrAuthor[]> => {
try {
const response = await this.axios.get<ReadarrAuthor[]>(
'/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<ReadarrBook[]> => {
try {
const response = await this.axios.get<ReadarrBook[]>('/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<ReadarrBook> => {
try {
const response = await this.axios.get<ReadarrBook>(`/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<ReadarrBook> => {
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<ReadarrBook>('/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<ReadarrMetadataProfile[]>('/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<void> => {
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;

View File

@@ -9,6 +9,8 @@ export enum MediaRequestStatus {
export enum MediaType { export enum MediaType {
MOVIE = 'movie', MOVIE = 'movie',
TV = 'tv', TV = 'tv',
MUSIC = 'music',
BOOK = 'book',
} }
export enum MediaStatus { export enum MediaStatus {

View File

@@ -29,6 +29,10 @@ export enum Permission {
WATCHLIST_VIEW = 134217728, WATCHLIST_VIEW = 134217728,
MANAGE_BLOCKLIST = 268435456, MANAGE_BLOCKLIST = 268435456,
VIEW_BLOCKLIST = 1073741824, VIEW_BLOCKLIST = 1073741824,
REQUEST_MUSIC = 536870912,
AUTO_APPROVE_MUSIC = 2147483648,
REQUEST_BOOK = 4294967296,
AUTO_APPROVE_BOOK = 8589934592,
} }
export interface PermissionCheckOptions { export interface PermissionCheckOptions {

View File

@@ -102,6 +102,16 @@ export interface SonarrSettings extends DVRSettings {
monitorNewItems: 'all' | 'none'; monitorNewItems: 'all' | 'none';
} }
export interface LidarrSettings extends DVRSettings {
activeMetadataProfileId: number;
activeMetadataProfileName: string;
}
export interface ReadarrSettings extends DVRSettings {
activeMetadataProfileId: number;
activeMetadataProfileName: string;
}
interface Quota { interface Quota {
quotaLimit?: number; quotaLimit?: number;
quotaDays?: number; quotaDays?: number;
@@ -137,6 +147,8 @@ export interface MainSettings {
defaultQuotas: { defaultQuotas: {
movie: Quota; movie: Quota;
tv: Quota; tv: Quota;
music: Quota;
book: Quota;
}; };
hideAvailable: boolean; hideAvailable: boolean;
hideBlocklisted: boolean; hideBlocklisted: boolean;
@@ -368,6 +380,8 @@ export interface AllSettings {
tautulli: TautulliSettings; tautulli: TautulliSettings;
radarr: RadarrSettings[]; radarr: RadarrSettings[];
sonarr: SonarrSettings[]; sonarr: SonarrSettings[];
lidarr: LidarrSettings[];
readarr: ReadarrSettings[];
public: PublicSettings; public: PublicSettings;
notifications: NotificationSettings; notifications: NotificationSettings;
jobs: Record<JobId, JobSettings>; jobs: Record<JobId, JobSettings>;
@@ -398,6 +412,8 @@ class Settings {
defaultQuotas: { defaultQuotas: {
movie: {}, movie: {},
tv: {}, tv: {},
music: {},
book: {},
}, },
hideAvailable: false, hideAvailable: false,
hideBlocklisted: false, hideBlocklisted: false,
@@ -441,6 +457,8 @@ class Settings {
}, },
radarr: [], radarr: [],
sonarr: [], sonarr: [],
lidarr: [],
readarr: [],
public: { public: {
initialized: false, initialized: false,
}, },
@@ -673,6 +691,22 @@ class Settings {
this.data.sonarr = data; 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 { get public(): PublicSettings {
return this.data.public; return this.data.public;
} }

94
server/routes/book.ts Normal file
View File

@@ -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;

View File

@@ -30,12 +30,14 @@ import { isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express'; import { Router } from 'express';
import authRoutes from './auth'; import authRoutes from './auth';
import blocklistRoutes from './blocklist'; import blocklistRoutes from './blocklist';
import bookRoutes from './book';
import collectionRoutes from './collection'; import collectionRoutes from './collection';
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover'; import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
import issueRoutes from './issue'; import issueRoutes from './issue';
import issueCommentRoutes from './issueComment'; import issueCommentRoutes from './issueComment';
import mediaRoutes from './media'; import mediaRoutes from './media';
import movieRoutes from './movie'; import movieRoutes from './movie';
import musicRoutes from './music';
import personRoutes from './person'; import personRoutes from './person';
import requestRoutes from './request'; import requestRoutes from './request';
import searchRoutes from './search'; import searchRoutes from './search';
@@ -165,6 +167,8 @@ router.use(
); );
router.use('/movie', isAuthenticated(), movieRoutes); router.use('/movie', isAuthenticated(), movieRoutes);
router.use('/tv', isAuthenticated(), tvRoutes); router.use('/tv', isAuthenticated(), tvRoutes);
router.use('/music', isAuthenticated(), musicRoutes);
router.use('/book', isAuthenticated(), bookRoutes);
router.use('/media', isAuthenticated(), mediaRoutes); router.use('/media', isAuthenticated(), mediaRoutes);
router.use('/person', isAuthenticated(), personRoutes); router.use('/person', isAuthenticated(), personRoutes);
router.use('/collection', isAuthenticated(), collectionRoutes); router.use('/collection', isAuthenticated(), collectionRoutes);

135
server/routes/music.ts Normal file
View File

@@ -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;

View File

@@ -41,7 +41,9 @@ import semver from 'semver';
import { URL } from 'url'; import { URL } from 'url';
import metadataRoutes from './metadata'; import metadataRoutes from './metadata';
import notificationRoutes from './notifications'; import notificationRoutes from './notifications';
import lidarrRoutes from './lidarr';
import radarrRoutes from './radarr'; import radarrRoutes from './radarr';
import readarrRoutes from './readarr';
import sonarrRoutes from './sonarr'; import sonarrRoutes from './sonarr';
const settingsRoutes = Router(); const settingsRoutes = Router();
@@ -49,6 +51,8 @@ const settingsRoutes = Router();
settingsRoutes.use('/notifications', notificationRoutes); settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes); settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes); settingsRoutes.use('/sonarr', sonarrRoutes);
settingsRoutes.use('/lidarr', lidarrRoutes);
settingsRoutes.use('/readarr', readarrRoutes);
settingsRoutes.use('/discover', discoverSettingRoutes); settingsRoutes.use('/discover', discoverSettingRoutes);
settingsRoutes.use('/metadatas', metadataRoutes); settingsRoutes.use('/metadatas', metadataRoutes);

View File

@@ -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<string, unknown>,
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;

View File

@@ -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<string, unknown>,
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;