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

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

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

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;