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:
271
server/api/servarr/lidarr.ts
Normal file
271
server/api/servarr/lidarr.ts
Normal 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;
|
||||
225
server/api/servarr/readarr.ts
Normal file
225
server/api/servarr/readarr.ts
Normal 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;
|
||||
@@ -9,6 +9,8 @@ export enum MediaRequestStatus {
|
||||
export enum MediaType {
|
||||
MOVIE = 'movie',
|
||||
TV = 'tv',
|
||||
MUSIC = 'music',
|
||||
BOOK = 'book',
|
||||
}
|
||||
|
||||
export enum MediaStatus {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<JobId, JobSettings>;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
94
server/routes/book.ts
Normal file
94
server/routes/book.ts
Normal 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;
|
||||
@@ -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
135
server/routes/music.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
106
server/routes/settings/lidarr.ts
Normal file
106
server/routes/settings/lidarr.ts
Normal 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;
|
||||
106
server/routes/settings/readarr.ts
Normal file
106
server/routes/settings/readarr.ts
Normal 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;
|
||||
Reference in New Issue
Block a user