feat(music): add Lidarr server settings and connectivity (#3109)

This commit is contained in:
Kjeld Schouten
2026-06-08 22:51:52 +02:00
committed by Gauthier
parent 7379c73703
commit 98cfc910bc
15 changed files with 1729 additions and 33 deletions

View File

@@ -11,7 +11,7 @@
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/seerr-team/seerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/seerr-team/seerr"></a>
**Seerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
**Seerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**, and **[Lidarr](https://lidarr.audio/)**.
## Current Features
@@ -19,7 +19,7 @@
- Support for **PostgreSQL** and **SQLite** databases.
- Supports Movies, Shows and Mixed Libraries.
- Ability to change email addresses for SMTP purposes.
- Easy integration with your existing services. Currently, Seerr supports Sonarr and Radarr. More to come!
- Easy integration with your existing services. Currently, Seerr supports Sonarr, Radarr, and Lidarr.
- Jellyfin/Emby/Plex library scan, to keep track of the titles which are already available.
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!

View File

@@ -7,14 +7,14 @@ sidebar_position: 1
Welcome to the Seerr Documentation.
**Seerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
**Seerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**, and **[Lidarr](https://lidarr.audio/)**.
## Features
- **Full Jellyfin/Emby/Plex integration**. Login and manage user access with Jellyfin/Emby/Plex.
- **Syncs to your Jellyfin/Emby/Plex library** to show what titles you already have.
- Supports Movies, Shows and Mixed Libraries.
- **Integrates with Sonarr and Radarr**. With more services to come in the future.
- **Integrates with Sonarr, Radarr, and Lidarr**.
- Optionally set **Override rules** for requests to match with your defined conditions.
- **Easy to use request system** allowing users to request individual seasons or movies in a friendly, clean UI.
- **Simple request management UI**. Don't dig through the app to approve recent requests.

View File

@@ -8,7 +8,7 @@ sidebar_position: 4
## Settings
All configurations from the **Settings** panel in the Seerr web UI are saved, including integrations with Radarr, Sonarr, Jellyfin, Plex, and notification settings.
All configurations from the **Settings** panel in the Seerr web UI are saved, including integrations with Radarr, Sonarr, Lidarr, Jellyfin, Plex, and notification settings.
These settings are stored in the `settings.json` file located in the Seerr data folder.
## User Data

View File

@@ -7,69 +7,77 @@ sidebar_position: 4
# Services
:::info
**If you keep separate copies of non-4K and 4K content in your media libraries, you will need to set up multiple Radarr/Sonarr instances and link each of them to Seerr.**
**If you keep separate copies of non-4K and 4K movies/series in your media libraries, you will need to set up multiple Radarr/Sonarr instances and link each of them to Seerr.**
Seerr checks these linked servers to determine whether or not media has already been requested or is available, so two servers of each type are required _if you keep separate non-4K and 4K copies of media._
Seerr checks these linked servers to determine whether or not media has already been requested or is available, so two servers of each type are required _if you keep separate non-4K and 4K copies of movies/series._
**If you only maintain one copy of media, you can instead simply set up one server and set the "Quality Profile" setting on a per-request basis.**
Lidarr does not use Seerr's 4K split workflow. Configure one or more Lidarr servers based on your music library setup.
:::
### Radarr/Sonarr Settings
### Radarr/Sonarr/Lidarr Settings
:::warning
**Only v3 & V4 Radarr/Sonarr servers are supported!** If your Radarr/Sonarr server is still running v2, you will need to upgrade in order to add it to Seerr.
::::
:::
#### Default Server
At least one server needs to be marked as "Default" in order for requests to be sent successfully to Radarr/Sonarr.
At least one server needs to be marked as "Default" in order for requests to be sent successfully to Radarr/Sonarr/Lidarr.
If you have separate 4K Radarr/Sonarr servers, you need to designate default 4K servers _in addition to_ default non-4K servers.
#### 4K Server
Only select this option if you have separate non-4K and 4K servers. If you only have a single Radarr/Sonarr server, do _not_ check this box!
Only select this option if you have separate non-4K and 4K servers. If you only have a single Radarr/Sonarr server, do _not_ check this box.
This option is only applicable to Radarr/Sonarr.
#### Server Name
Enter a friendly name for the Radarr/Sonarr server.
Enter a friendly name for the Radarr/Sonarr/Lidarr server.
#### Hostname or IP Address
If you have Seerr installed on the same network as Radarr/Sonarr, you can set this to the local IP address of your Radarr/Sonarr server. Otherwise, this should be set to a valid hostname (e.g., `radarr.myawesomeserver.com`).
If you have Seerr installed on the same network as Radarr/Sonarr/Lidarr, you can set this to the local IP address of your server. Otherwise, this should be set to a valid hostname (e.g., `radarr.myawesomeserver.com`).
#### Port
This value should be set to the port that your Radarr/Sonarr server listens on. By default, Radarr uses port `7878` and Sonarr uses port `8989`, but you may need to set this to `443` or some other value if your Radarr/Sonarr server is hosted on a VPS or cloud provider.
This value should be set to the port that your Radarr/Sonarr/Lidarr server listens on. By default, Radarr uses port `7878`, Sonarr uses port `8989`, and Lidarr uses port `8686`, but you may need to set this to `443` or some other value if your server is hosted on a VPS or cloud provider.
#### Use SSL
Enable this setting to connect to Radarr/Sonarr via HTTPS rather than HTTP. Self-signed certificates are not trusted by default, but you can configure Seerr to accept them. See [Self-Signed Certificates](/using-seerr/advanced/self-signed-certificates) for details.
Enable this setting to connect to Radarr/Sonarr/Lidarr via HTTPS rather than HTTP. Self-signed certificates are not trusted by default, but you can configure Seerr to accept them. See [Self-Signed Certificates](/using-seerr/advanced/self-signed-certificates) for details.
#### API Key
Enter your Radarr/Sonarr API key here. Do _not_ share these key publicly, as they can be used to gain administrator access to your Radarr/Sonarr servers!
Enter your Radarr/Sonarr/Lidarr API key here. Do _not_ share these keys publicly, as they can be used to gain administrator access to your servers.
You can locate the required API keys in Radarr/Sonarr in **Settings &rarr; General &rarr; Security**.
You can locate the required API keys in Radarr/Sonarr/Lidarr in **Settings &rarr; General &rarr; Security**.
#### URL Base
If you have configured a URL base for your Radarr/Sonarr server, you _must_ enter it here in order for Jellyeerr to connect to those services!
If you have configured a URL base for your Radarr/Sonarr/Lidarr server, you _must_ enter it here in order for Seerr to connect to those services.
You can verify whether or not you have a URL base configured in your Radarr/Sonarr server at **Settings &rarr; General &rarr; Host**. (Note that a restart of your Radarr/Sonarr server is required if you modify this setting!)
You can verify whether or not you have a URL base configured in your Radarr/Sonarr/Lidarr server at **Settings &rarr; General &rarr; Host**. (Note that a restart of your server is required if you modify this setting.)
#### Profiles, Root Folder, Minimum Availability
#### Profiles and Root Folder
Select the default settings you would like to use for all new requests. Note that all of these options are required, and that requests will fail if any of these are not configured!
Select the default settings you would like to use for all new requests.
For Radarr/Sonarr, ensure the required quality profile and root folder are set.
For Lidarr, ensure quality profile, metadata profile, and root folder are set.
#### External URL (optional)
If the hostname or IP address you configured above is not accessible outside your network, you can set a different URL here. This "external" URL is used to add clickable links to your Radarr/Sonarr servers on media detail pages.
If the hostname or IP address you configured above is not accessible outside your network, you can set a different URL here. This "external" URL is used to add clickable links to your Radarr/Sonarr/Lidarr servers on media detail pages.
#### Enable Scan (optional)
Enable this setting if you would like to scan your Radarr/Sonarr server for existing media/request status. It is recommended that you enable this setting, so that users cannot submit requests for media which has already been requested or is already available.
Enable this setting if you would like to scan your Radarr/Sonarr/Lidarr server for existing media/request status. It is recommended that you enable this setting, so that users cannot submit requests for media which has already been requested or is already available.
#### Enable Automatic Search (optional)
Enable this setting to have Radarr/Sonarr to automatically search for media upon approval of a request.
Enable this setting to have Radarr/Sonarr/Lidarr automatically search for media upon approval of a request.

View File

@@ -35,7 +35,7 @@ tags:
- name: collection
description: Endpoints related to retrieving collection details.
- name: service
description: Endpoints related to getting service (Radarr/Sonarr) details.
description: Endpoints related to getting service (Radarr/Sonarr/Lidarr) details.
- name: watchlist
description: Collection of media to watch later
- name: blocklist
@@ -691,6 +691,69 @@ components:
- is4k
- enableSeasonFolders
- isDefault
LidarrSettings:
type: object
properties:
id:
type: number
example: 0
readOnly: true
name:
type: string
example: 'Lidarr Main'
hostname:
type: string
example: '127.0.0.1'
port:
type: number
example: 8686
apiKey:
type: string
example: 'exampleapikey'
useSsl:
type: boolean
example: false
baseUrl:
type: string
activeProfileId:
type: number
example: 1
activeProfileName:
type: string
example: Any
activeDirectory:
type: string
example: '/music'
activeMetadataProfileId:
type: number
example: 1
activeMetadataProfileName:
type: string
example: Standard
isDefault:
type: boolean
example: false
externalUrl:
type: string
example: http://lidarr.example.com
syncEnabled:
type: boolean
example: false
preventSearch:
type: boolean
example: false
required:
- name
- hostname
- port
- apiKey
- useSsl
- activeProfileId
- activeProfileName
- activeDirectory
- activeMetadataProfileId
- activeMetadataProfileName
- isDefault
ServarrTag:
type: object
properties:
@@ -2957,6 +3020,128 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/SonarrSettings'
/settings/lidarr:
get:
summary: Get Lidarr settings
description: Returns all Lidarr settings in a JSON array.
tags:
- settings
responses:
'200':
description: 'Values were returned'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/LidarrSettings'
post:
summary: Create Lidarr instance
description: Creates a new Lidarr instance from the request body.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LidarrSettings'
responses:
'201':
description: 'New Lidarr instance created'
content:
application/json:
schema:
$ref: '#/components/schemas/LidarrSettings'
/settings/lidarr/test:
post:
summary: Test Lidarr configuration
description: Tests if the Lidarr configuration is valid. Returns profiles and root folders on success.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
hostname:
type: string
example: '127.0.0.1'
port:
type: number
example: 8686
apiKey:
type: string
example: yourapikey
useSsl:
type: boolean
example: false
baseUrl:
type: string
required:
- hostname
- port
- apiKey
- useSsl
responses:
'200':
description: Successfully connected to Lidarr instance
content:
application/json:
schema:
type: object
properties:
profiles:
type: array
items:
$ref: '#/components/schemas/ServiceProfile'
/settings/lidarr/{lidarrId}:
put:
summary: Update Lidarr instance
description: Updates an existing Lidarr instance with the provided values.
tags:
- settings
parameters:
- in: path
name: lidarrId
required: true
schema:
type: integer
description: Lidarr instance ID
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LidarrSettings'
responses:
'200':
description: 'Lidarr instance updated'
content:
application/json:
schema:
$ref: '#/components/schemas/LidarrSettings'
delete:
summary: Delete Lidarr instance
description: Deletes an existing Lidarr instance based on the lidarrId parameter.
tags:
- settings
parameters:
- in: path
name: lidarrId
required: true
schema:
type: integer
description: Lidarr instance ID
responses:
'200':
description: 'Lidarr instance deleted'
content:
application/json:
schema:
$ref: '#/components/schemas/LidarrSettings'
/settings/public:
get:
summary: Get public settings
@@ -7362,6 +7547,46 @@ paths:
type: array
items:
$ref: '#/components/schemas/SonarrSeries'
/service/lidarr:
get:
summary: Get non-sensitive Lidarr server list
description: Returns a list of Lidarr server IDs and names in a JSON object.
tags:
- service
responses:
'200':
description: Request successful
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/LidarrSettings'
/service/lidarr/{lidarrId}:
get:
summary: Get Lidarr server quality profiles and root folders
description: Returns a Lidarr server's quality profile and root folder details in a JSON object.
tags:
- service
parameters:
- in: path
name: lidarrId
required: true
schema:
type: number
example: 0
responses:
'200':
description: Request successful
content:
application/json:
schema:
type: object
properties:
server:
$ref: '#/components/schemas/LidarrSettings'
profiles:
$ref: '#/components/schemas/ServiceProfile'
/regions:
get:
summary: Regions supported by TMDB

View File

@@ -0,0 +1,305 @@
import logger from '@server/logger';
import ServarrBase from './base';
export interface LidarrImage {
url: string;
coverType: string;
}
export interface LidarrRating {
votes: number;
value: number;
}
export interface LidarrLink {
url: string;
name: string;
}
export interface LidarrAlbumOptions {
[key: string]: unknown;
title: string;
disambiguation?: string;
overview?: string;
artistId: number;
foreignAlbumId: string;
monitored: boolean;
anyReleaseOk: boolean;
profileId: number;
duration?: number;
albumType: string;
secondaryTypes: string[];
mediumCount?: number;
ratings?: LidarrRating;
releaseDate?: string;
releases: unknown[];
genres: string[];
media: unknown[];
artist: {
status: string;
ended: boolean;
artistName: string;
foreignArtistId: string;
tadbId?: number;
discogsId?: number;
overview?: string;
artistType: string;
disambiguation?: string;
links: LidarrLink[];
images: LidarrImage[];
path: string;
qualityProfileId: number;
metadataProfileId: number;
monitored: boolean;
monitorNewItems: string;
rootFolderPath: string;
genres: string[];
cleanName?: string;
sortName?: string;
tags: number[];
added?: string;
ratings?: LidarrRating;
id: number;
};
images: LidarrImage[];
links: LidarrLink[];
addOptions: {
searchForNewAlbum: boolean;
};
}
export interface LidarrAlbum {
id: number;
mbId: string;
title: string;
monitored: boolean;
artistId: number;
foreignAlbumId: string;
titleSlug: string;
profileId: number;
duration: number;
albumType: string;
statistics: {
trackFileCount: number;
trackCount: number;
totalTrackCount: number;
sizeOnDisk: number;
percentOfTracks: number;
};
}
export interface MetadataProfile {
id: number;
name: string;
}
class LidarrAPI extends ServarrBase<{ albumId: number }> {
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super({ url, apiKey, cacheName: 'lidarr', apiName: 'Lidarr' });
}
public getAlbums = async (): Promise<LidarrAlbum[]> => {
try {
const response = await this.axios.get<LidarrAlbum[]>('/album');
return response.data;
} catch (e) {
throw new Error(`[Lidarr] Failed to retrieve albums: ${e.message}`, {
cause: e,
});
}
};
public async getAlbumById(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 by ID: ${e.message}`, {
cause: e,
});
}
}
public async getAlbumByForeignAlbumId(mbid: string): Promise<LidarrAlbum> {
try {
const response = await this.axios.get<LidarrAlbum[]>('/album/lookup', {
params: {
term: `lidarr:${mbid}`,
},
});
if (!response.data[0]) {
throw new Error('Album not found');
}
return response.data[0];
} catch (e) {
logger.error('Error retrieving album by MusicBrainz ID', {
label: 'Lidarr API',
errorMessage: e.message,
mbid,
});
throw new Error('Album not found', { cause: e });
}
}
public async addAlbum(options: LidarrAlbumOptions): Promise<LidarrAlbum> {
try {
const album = await this.getAlbumByForeignAlbumId(options.foreignAlbumId);
// If the album already exists in Lidarr, just update its monitored flag
if (album.id) {
const updatedAlbumResponse = await this.axios.put<LidarrAlbum>(
`/album/${album.id}`,
{
...album,
monitored: options.monitored ?? album.monitored,
}
);
if (updatedAlbumResponse.data.id) {
logger.info('Updated existing album in Lidarr.', {
label: 'Lidarr',
albumId: updatedAlbumResponse.data.id,
albumTitle: updatedAlbumResponse.data.title,
});
logger.debug('Lidarr update details', {
label: 'Lidarr',
album: updatedAlbumResponse.data,
});
if (options.addOptions?.searchForNewAlbum) {
this.searchAlbum(updatedAlbumResponse.data.id);
}
return updatedAlbumResponse.data;
} else {
logger.error('Failed to update album in Lidarr', {
label: 'Lidarr',
options,
});
throw new Error('Failed to update album in Lidarr');
}
}
const createdAlbumResponse = await this.axios.post<LidarrAlbum>(
'/album',
{
...options,
monitored: options.monitored ?? true,
}
);
if (createdAlbumResponse.data.id) {
logger.info('Lidarr accepted request', { label: 'Lidarr' });
logger.debug('Lidarr add details', {
label: 'Lidarr',
album: createdAlbumResponse.data,
});
} else {
logger.error('Failed to add album to Lidarr', {
label: 'Lidarr',
options,
});
throw new Error('Failed to add album to Lidarr');
}
return createdAlbumResponse.data;
} catch (e) {
logger.error('Something went wrong while adding an album to Lidarr.', {
label: 'Lidarr API',
errorMessage: e.message,
options,
response: e?.response?.data,
});
throw new Error('Failed to add album', { cause: e });
}
}
public removeAlbum = async (mbid: string): Promise<void> => {
try {
const { id, title } = await this.getAlbumByForeignAlbumId(mbid);
await this.axios.delete(`/album/${id}`, {
params: {
deleteFiles: true,
addImportExclusion: false,
},
});
logger.info(`[Lidarr] Removed album ${title}`);
} catch (e) {
throw new Error(`[Lidarr] Failed to remove album: ${e.message}`, {
cause: e,
});
}
};
public async searchAlbum(albumId: number): Promise<void> {
logger.info('Executing album search command.', {
label: 'Lidarr API',
albumId,
});
try {
await this.runCommand('AlbumSearch', { albumIds: [albumId] });
} catch (e) {
logger.error(
'Something went wrong while executing Lidarr album search.',
{
label: 'Lidarr API',
errorMessage: e.message,
albumId,
}
);
}
}
public async getMetadataProfiles(): Promise<MetadataProfile[]> {
try {
const data = await this.getRolling<MetadataProfile[]>(
'/metadataprofile',
undefined,
3600
);
return data;
} catch (e) {
logger.error(
'Something went wrong while retrieving Lidarr metadata profiles.',
{
label: 'Lidarr API',
errorMessage: e.message,
}
);
throw new Error('Failed to get metadata profiles', { cause: e });
}
}
public clearCache = ({
mbId,
externalId,
title,
}: {
mbId?: string | null;
externalId?: number | null;
title?: string | null;
}) => {
if (mbId) {
this.removeCache('/album/lookup', {
term: `lidarr:${mbId}`,
});
}
if (externalId) {
this.removeCache(`/album/${externalId}`);
}
if (title) {
this.removeCache('/album/lookup', {
term: title,
});
}
};
}
export default LidarrAPI;

View File

@@ -10,7 +10,8 @@ export type AvailableCacheIds =
| 'plexguid'
| 'plextv'
| 'plexwatchlist'
| 'tvdb';
| 'tvdb'
| 'lidarr';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
@@ -50,6 +51,7 @@ class CacheManager {
}),
radarr: new Cache('radarr', 'Radarr API'),
sonarr: new Cache('sonarr', 'Sonarr API'),
lidarr: new Cache('lidarr', 'Lidarr API'),
rt: new Cache('rt', 'Rotten Tomatoes API', {
stdTtl: 43200,
checkPeriod: 60 * 30,

View File

@@ -90,6 +90,11 @@ export interface RadarrSettings extends DVRSettings {
minimumAvailability: string;
}
export interface LidarrSettings extends DVRSettings {
activeMetadataProfileId: number;
activeMetadataProfileName: string;
}
export interface SonarrSettings extends DVRSettings {
seriesType: 'standard' | 'daily' | 'anime';
animeSeriesType: 'standard' | 'daily' | 'anime';
@@ -379,6 +384,7 @@ export interface AllSettings {
tautulli: TautulliSettings;
radarr: RadarrSettings[];
sonarr: SonarrSettings[];
lidarr: LidarrSettings[];
public: PublicSettings;
notifications: NotificationSettings;
jobs: Record<JobId, JobSettings>;
@@ -455,6 +461,7 @@ class Settings {
},
radarr: [],
sonarr: [],
lidarr: [],
public: {
initialized: false,
},
@@ -693,6 +700,14 @@ class Settings {
this.data.sonarr = data;
}
get lidarr(): LidarrSettings[] {
return this.data.lidarr;
}
set lidarr(data: LidarrSettings[]) {
this.data.lidarr = data;
}
get public(): PublicSettings {
return this.data.public;
}

View File

@@ -1,3 +1,4 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
@@ -213,4 +214,76 @@ serviceRoutes.get<{ tmdbId: string }>(
}
);
serviceRoutes.get('/lidarr', async (req, res) => {
const settings = getSettings();
const filteredLidarrServers: ServiceCommonServer[] = settings.lidarr.map(
(lidarr) => ({
id: lidarr.id,
name: lidarr.name,
is4k: false,
isDefault: lidarr.isDefault,
activeDirectory: lidarr.activeDirectory,
activeProfileId: lidarr.activeProfileId,
activeTags: lidarr.tags ?? [],
})
);
return res.status(200).json(filteredLidarrServers);
});
serviceRoutes.get<{ lidarrId: string }>(
'/lidarr/:lidarrId',
async (req, res, next) => {
const settings = getSettings();
const lidarrSettings = settings.lidarr.find(
(lidarr) => lidarr.id === Number(req.params.lidarrId)
);
if (!lidarrSettings) {
return next({
status: 404,
message: 'Lidarr server with provided ID does not exist.',
});
}
const lidarr = new LidarrAPI({
apiKey: lidarrSettings.apiKey,
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
});
try {
const profiles = await lidarr.getProfiles();
const rootFolders = await lidarr.getRootFolders();
const tags = await lidarr.getTags();
return res.status(200).json({
server: {
id: lidarrSettings.id,
name: lidarrSettings.name,
is4k: false,
isDefault: lidarrSettings.isDefault,
activeDirectory: lidarrSettings.activeDirectory,
activeProfileId: lidarrSettings.activeProfileId,
activeTags: lidarrSettings.tags,
},
profiles: profiles.map((profile) => ({
id: profile.id,
name: profile.name,
})),
rootFolders: rootFolders.map((folder) => ({
id: folder.id,
freeSpace: folder.freeSpace,
path: folder.path,
totalSpace: folder.totalSpace,
})),
tags,
} as ServiceCommonServerWithDetails);
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
export default serviceRoutes;

View File

@@ -39,6 +39,7 @@ import { rescheduleJob } from 'node-schedule';
import path from 'path';
import semver from 'semver';
import { URL } from 'url';
import lidarrRoutes from './lidarr';
import metadataRoutes from './metadata';
import notificationRoutes from './notifications';
import radarrRoutes from './radarr';
@@ -49,6 +50,7 @@ const settingsRoutes = Router();
settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes);
settingsRoutes.use('/lidarr', lidarrRoutes);
settingsRoutes.use('/discover', discoverSettingRoutes);
settingsRoutes.use('/metadatas', metadataRoutes);

View File

@@ -0,0 +1,118 @@
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 we are setting this as the default, clear any previous defaults first
if (req.body.isDefault) {
settings.lidarr.forEach((lidarrInstance) => {
lidarrInstance.isDefault = false;
});
}
settings.lidarr = [...settings.lidarr, newLidarr];
await settings.save();
return res.status(201).json(newLidarr);
});
lidarrRoutes.post('/test', async (req, res, next) => {
try {
const lidarr = new LidarrAPI({
apiKey: req.body.apiKey,
url: LidarrAPI.buildUrl(req.body, '/api/v1'),
});
const urlBase = await lidarr
.getSystemStatus()
.then((value) => value.urlBase);
const profiles = await lidarr.getProfiles();
const metadataProfiles = await lidarr.getMetadataProfiles();
const folders = await lidarr.getRootFolders();
const tags = await lidarr.getTags();
return res.status(200).json({
profiles,
metadataProfiles,
rootFolders: folders.map((folder) => ({
id: folder.id,
path: folder.path,
})),
tags,
urlBase,
});
} 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) => {
const settings = getSettings();
const lidarrIndex = settings.lidarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (lidarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
// If we are setting this as the default, clear any previous defaults first
if (req.body.isDefault) {
settings.lidarr.forEach((lidarrInstance) => {
lidarrInstance.isDefault = false;
});
}
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) => {
const settings = getSettings();
const lidarrIndex = settings.lidarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (lidarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance 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 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 flex-shrink-0" viewBox="0 0 1024 1024"><style>.lidarr_svg__a{fill:#989898;stroke-width:24}.lidarr_svg__b{fill:none;stroke-width:16;stroke:#009252}</style><path fill="none" d="M-1-1h1026v1026H-1z"></path><circle cx="512" cy="512" r="410" stroke-width="1.8"></circle><circle cx="512" cy="512" r="460" style="fill: none; stroke-width: 99; stroke: rgb(229, 229, 229);"></circle><circle cx="512" cy="512" r="270" style="fill: rgb(229, 229, 229); stroke-width: 76; stroke: rgb(229, 229, 229);"></circle><circle cy="512" cx="512" r="410" style="fill: none; stroke-width: 12; stroke: rgb(0, 146, 82);"></circle><path d="M512 636V71L182 636h330zM512 388v565l330-565H512z"></path><path d="M512 636V71L182 636h330zM512 388v565l330-565H512z" class="lidarr_svg__b"></path><circle cx="512" cy="512" r="150" style="fill: rgb(0, 146, 82);"></circle></svg>

After

Width:  |  Height:  |  Size: 897 B

View File

@@ -0,0 +1,781 @@
import Modal from '@app/components/Common/Modal';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import type { LidarrTestResponse } from '@app/components/Settings/SettingsServices';
import useToasts from '@app/hooks/useToasts';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { isValidURL } from '@app/utils/urlValidationHelper';
import { Transition } from '@headlessui/react';
import type { LidarrSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import type { OnChangeValue } from 'react-select';
import Select from 'react-select';
import * as Yup from 'yup';
type OptionType = {
value: number;
label: string;
};
const messages = defineMessages('components.Settings.LidarrModal', {
createlidarr: 'Add New Lidarr Server',
editlidarr: 'Edit Lidarr Server',
validationNameRequired: 'You must provide a server name',
validationHostnameRequired: 'You must provide a valid hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
validationApiKeyRequired: 'You must provide an API key',
validationRootFolderRequired: 'You must select a root folder',
validationProfileRequired: 'You must select a quality profile',
validationMetadataProfileRequired: 'You must select a metadata profile',
toastLidarrTestSuccess: 'Lidarr connection established successfully!',
toastLidarrTestFailure: 'Failed to connect to Lidarr.',
add: 'Add Server',
defaultserver: 'Default Server',
servername: 'Server Name',
hostname: 'Hostname or IP Address',
port: 'Port',
ssl: 'Use SSL',
apiKey: 'API Key',
baseUrl: 'URL Base',
qualityprofile: 'Quality Profile',
metadataprofile: 'Metadata Profile',
rootfolder: 'Root Folder',
selectQualityProfile: 'Select quality profile',
selectRootFolder: 'Select root folder',
selectMetadataProfile: 'Select metadata profile',
loadingprofiles: 'Loading quality profiles…',
testFirstQualityProfiles: 'Test connection to load quality profiles',
loadingrootfolders: 'Loading root folders…',
testFirstRootFolders: 'Test connection to load root folders',
loadingmetadataprofiles: 'Loading metadata profiles…',
testFirstMetadataProfiles: 'Test connection to load metadata profiles',
loadingTags: 'Loading tags…',
testFirstTags: 'Test connection to load tags',
syncEnabled: 'Enable Scan',
externalUrl: 'External URL',
enableSearch: 'Enable Automatic Search',
tagRequests: 'Tag Requests',
tagRequestsInfo:
"Automatically add an additional tag with the requester's user ID & display name",
validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
validationBaseUrlTrailingSlash: 'Base URL must not end in a trailing slash',
tags: 'Tags',
notagoptions: 'No tags.',
selecttags: 'Select tags',
apiKeyHelp: 'Find it in Lidarr: Settings > General > Security > API Key',
baseUrlHelp:
'If you set a URL Base in Lidarr (Settings > General > Host), enter it here (e.g. /lidarr). Leave blank otherwise.',
externalUrlHelp:
'For clickable links on media pages when the hostname is not reachable from outside your network.',
syncEnabledHelp:
'Scan Lidarr for existing media and request status so users cannot request content already available.',
enableSearchHelp:
'Automatically trigger a search in Lidarr when a request is approved.',
});
interface LidarrModalProps {
lidarr: LidarrSettings | null;
onClose: () => void;
onSave: () => void;
}
const LidarrModal = ({ onClose, lidarr, onSave }: LidarrModalProps) => {
const intl = useIntl();
const initialLoad = useRef(false);
const { addToast } = useToasts();
const [isValidated, setIsValidated] = useState(lidarr ? true : false);
const [isTesting, setIsTesting] = useState(false);
const [testResponse, setTestResponse] = useState<LidarrTestResponse>({
profiles: [],
rootFolders: [],
metadataProfiles: [],
tags: [],
});
const LidarrSettingsSchema = Yup.object().shape({
name: Yup.string().required(
intl.formatMessage(messages.validationNameRequired)
),
hostname: Yup.string().required(
intl.formatMessage(messages.validationHostnameRequired)
),
port: Yup.number()
.nullable()
.required(intl.formatMessage(messages.validationPortRequired)),
apiKey: Yup.string().required(
intl.formatMessage(messages.validationApiKeyRequired)
),
rootFolder: Yup.string().required(
intl.formatMessage(messages.validationRootFolderRequired)
),
activeProfileId: Yup.string().required(
intl.formatMessage(messages.validationProfileRequired)
),
activeMetadataProfileId: Yup.string().required(
intl.formatMessage(messages.validationMetadataProfileRequired)
),
externalUrl: Yup.string()
.test(
'valid-url',
intl.formatMessage(messages.validationApplicationUrl),
isValidURL
)
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
(value) => !value || !value.endsWith('/')
),
baseUrl: Yup.string()
.test(
'leading-slash',
intl.formatMessage(messages.validationBaseUrlLeadingSlash),
(value) => !value || value.startsWith('/')
)
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationBaseUrlTrailingSlash),
(value) => !value || !value.endsWith('/')
),
});
const testConnection = useCallback(
async ({
hostname,
port,
apiKey,
baseUrl,
useSsl = false,
}: {
hostname: string;
port: number;
apiKey: string;
baseUrl?: string;
useSsl?: boolean;
}) => {
setIsTesting(true);
try {
const response = await axios.post<LidarrTestResponse>(
'/api/v1/settings/lidarr/test',
{
hostname,
apiKey,
port: Number(port),
baseUrl,
useSsl,
}
);
setIsValidated(true);
setTestResponse(response.data);
if (initialLoad.current) {
addToast(intl.formatMessage(messages.toastLidarrTestSuccess), {
appearance: 'success',
autoDismiss: true,
});
}
} catch {
setIsValidated(false);
if (initialLoad.current) {
addToast(intl.formatMessage(messages.toastLidarrTestFailure), {
appearance: 'error',
autoDismiss: true,
});
}
} finally {
setIsTesting(false);
initialLoad.current = true;
}
},
[addToast, intl]
);
useEffect(() => {
if (lidarr) {
testConnection({
apiKey: lidarr.apiKey,
hostname: lidarr.hostname,
port: lidarr.port,
baseUrl: lidarr.baseUrl,
useSsl: lidarr.useSsl,
});
}
}, [lidarr, testConnection]);
return (
<Transition
as="div"
appear
show
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Formik
initialValues={{
name: lidarr?.name,
hostname: lidarr?.hostname,
port: lidarr?.port ?? 8686,
ssl: lidarr?.useSsl ?? false,
apiKey: lidarr?.apiKey,
baseUrl: lidarr?.baseUrl,
activeProfileId: lidarr?.activeProfileId,
activeMetadataProfileId: lidarr?.activeMetadataProfileId,
rootFolder: lidarr?.activeDirectory,
tags: lidarr?.tags ?? [],
isDefault: lidarr?.isDefault ?? false,
externalUrl: lidarr?.externalUrl,
syncEnabled: lidarr?.syncEnabled ?? false,
enableSearch: !lidarr?.preventSearch,
tagRequests: lidarr?.tagRequests ?? false,
}}
validationSchema={LidarrSettingsSchema}
onSubmit={async (values) => {
try {
const profileName = testResponse.profiles.find(
(profile) => profile.id === Number(values.activeProfileId)
)?.name;
const metadataProfileName = testResponse.metadataProfiles.find(
(profile) => profile.id === Number(values.activeMetadataProfileId)
)?.name;
const submission = {
name: values.name,
hostname: values.hostname,
port: Number(values.port),
apiKey: values.apiKey,
useSsl: values.ssl,
baseUrl: values.baseUrl,
activeProfileId: Number(values.activeProfileId),
activeMetadataProfileId: Number(values.activeMetadataProfileId),
activeProfileName: profileName,
activeMetadataProfileName: metadataProfileName,
activeDirectory: values.rootFolder,
tags: values.tags,
isDefault: values.isDefault,
externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled,
preventSearch: !values.enableSearch,
tagRequests: values.tagRequests,
};
if (!lidarr) {
await axios.post('/api/v1/settings/lidarr', submission);
} else {
await axios.put(
`/api/v1/settings/lidarr/${lidarr.id}`,
submission
);
}
onSave();
} catch {
// set error here
}
}}
>
{({
errors,
touched,
values,
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
}) => {
return (
<Modal
onCancel={onClose}
okButtonType="primary"
okText={
isSubmitting
? intl.formatMessage(globalMessages.saving)
: lidarr
? intl.formatMessage(globalMessages.save)
: intl.formatMessage(messages.add)
}
secondaryButtonType="warning"
secondaryText={
isTesting
? intl.formatMessage(globalMessages.testing)
: intl.formatMessage(globalMessages.test)
}
onSecondary={() => {
if (values.apiKey && values.hostname && values.port) {
testConnection({
apiKey: values.apiKey,
baseUrl: values.baseUrl,
hostname: values.hostname,
port: values.port,
useSsl: values.ssl,
});
if (!values.baseUrl || values.baseUrl === '/') {
setFieldValue('baseUrl', testResponse.urlBase);
}
}
}}
secondaryDisabled={
!values.apiKey ||
!values.hostname ||
!values.port ||
isTesting ||
isSubmitting
}
okDisabled={!isValidated || isSubmitting || isTesting || !isValid}
onOk={() => handleSubmit()}
title={
!lidarr
? intl.formatMessage(messages.createlidarr)
: intl.formatMessage(messages.editlidarr)
}
>
<div className="mb-6">
<div className="form-row">
<label htmlFor="isDefault" className="checkbox-label">
{intl.formatMessage(messages.defaultserver)}
</label>
<div className="form-input-area">
<Field type="checkbox" id="isDefault" name="isDefault" />
</div>
</div>
<div className="form-row">
<label htmlFor="name" className="text-label">
{intl.formatMessage(messages.servername)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="name"
name="name"
type="text"
autoComplete="off"
data-form-type="other"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setIsValidated(false);
setFieldValue('name', e.target.value);
}}
/>
</div>
{errors.name &&
touched.name &&
typeof errors.name === 'string' && (
<div className="error">{errors.name}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="hostname" className="text-label">
{intl.formatMessage(messages.hostname)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<span className="protocol">
{values.ssl ? 'https://' : 'http://'}
</span>
<Field
id="hostname"
name="hostname"
type="text"
inputMode="url"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setIsValidated(false);
setFieldValue('hostname', e.target.value);
}}
className="rounded-r-only"
/>
</div>
{errors.hostname &&
touched.hostname &&
typeof errors.hostname === 'string' && (
<div className="error">{errors.hostname}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="port" className="text-label">
{intl.formatMessage(messages.port)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<Field
id="port"
name="port"
type="text"
inputMode="numeric"
className="short"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setIsValidated(false);
setFieldValue('port', e.target.value);
}}
/>
{errors.port &&
touched.port &&
typeof errors.port === 'string' && (
<div className="error">{errors.port}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="ssl" className="checkbox-label">
{intl.formatMessage(messages.ssl)}
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="ssl"
name="ssl"
onChange={() => {
setIsValidated(false);
setFieldValue('ssl', !values.ssl);
}}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="apiKey" className="text-label">
{intl.formatMessage(messages.apiKey)}
<span className="label-required">*</span>
<span className="label-tip">
{intl.formatMessage(messages.apiKeyHelp)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
as="field"
id="apiKey"
name="apiKey"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setIsValidated(false);
setFieldValue('apiKey', e.target.value);
}}
/>
</div>
{errors.apiKey &&
touched.apiKey &&
typeof errors.apiKey === 'string' && (
<div className="error">{errors.apiKey}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="baseUrl" className="text-label">
{intl.formatMessage(messages.baseUrl)}
<span className="label-tip">
{intl.formatMessage(messages.baseUrlHelp)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="baseUrl"
name="baseUrl"
type="text"
inputMode="url"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setIsValidated(false);
setFieldValue('baseUrl', e.target.value);
}}
/>
</div>
{errors.baseUrl &&
touched.baseUrl &&
typeof errors.baseUrl === 'string' && (
<div className="error">{errors.baseUrl}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="activeProfileId" className="text-label">
{intl.formatMessage(messages.qualityprofile)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeProfileId"
name="activeProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingprofiles)
: !isValidated
? intl.formatMessage(
messages.testFirstQualityProfiles
)
: intl.formatMessage(
messages.selectQualityProfile
)}
</option>
{testResponse.profiles.length > 0 &&
testResponse.profiles
.toSorted((a, b) =>
a.name.localeCompare(b.name, intl.locale, {
numeric: true,
sensitivity: 'base',
})
)
.map((profile) => (
<option
key={`loaded-profile-${profile.id}`}
value={profile.id}
>
{profile.name}
</option>
))}
</Field>
</div>
{errors.activeProfileId &&
touched.activeProfileId &&
typeof errors.activeProfileId === 'string' && (
<div className="error">{errors.activeProfileId}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="rootFolder" className="text-label">
{intl.formatMessage(messages.rootfolder)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="rootFolder"
name="rootFolder"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingrootfolders)
: !isValidated
? intl.formatMessage(
messages.testFirstRootFolders
)
: intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.length > 0 &&
testResponse.rootFolders.map((folder) => (
<option
key={`loaded-profile-${folder.id}`}
value={folder.path}
>
{folder.path}
</option>
))}
</Field>
</div>
{errors.rootFolder &&
touched.rootFolder &&
typeof errors.rootFolder === 'string' && (
<div className="error">{errors.rootFolder}</div>
)}
</div>
</div>
<div className="form-row">
<label
htmlFor="activeMetadataProfileId"
className="text-label"
>
{intl.formatMessage(messages.metadataprofile)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeMetadataProfileId"
name="activeMetadataProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(
messages.loadingmetadataprofiles
)
: !isValidated
? intl.formatMessage(
messages.testFirstMetadataProfiles
)
: intl.formatMessage(
messages.selectMetadataProfile
)}
</option>
{testResponse.metadataProfiles.length > 0 &&
testResponse.metadataProfiles
.toSorted((a, b) =>
a.name.localeCompare(b.name, intl.locale, {
numeric: true,
sensitivity: 'base',
})
)
.map((profile) => (
<option
key={`loaded-metadataprofile-${profile.id}`}
value={profile.id}
>
{profile.name}
</option>
))}
</Field>
</div>
{errors.activeMetadataProfileId &&
touched.activeMetadataProfileId &&
typeof errors.activeMetadataProfileId === 'string' && (
<div className="error">
{errors.activeMetadataProfileId}
</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
</label>
<div className="form-input-area">
<Select<OptionType, true>
options={
isValidated
? testResponse.tags.map((tag) => ({
label: tag.label,
value: tag.id,
}))
: []
}
isMulti
isDisabled={!isValidated || isTesting}
placeholder={
!isValidated
? intl.formatMessage(messages.testFirstTags)
: isTesting
? intl.formatMessage(messages.loadingTags)
: intl.formatMessage(messages.selecttags)
}
isLoading={isTesting}
className="react-select-container"
classNamePrefix="react-select"
value={
isTesting
? []
: (values.tags
.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === tagId
);
if (!foundTag) {
return undefined;
}
return {
value: foundTag.id,
label: foundTag.label,
};
})
.filter(
(option) => option !== undefined
) as OptionType[])
}
onChange={(value: OnChangeValue<OptionType, true>) => {
setFieldValue(
'tags',
value.map((option) => option.value)
);
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="externalUrl" className="text-label">
{intl.formatMessage(messages.externalUrl)}
<span className="label-tip">
{intl.formatMessage(messages.externalUrlHelp)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="externalUrl"
name="externalUrl"
type="text"
inputMode="url"
/>
</div>
{errors.externalUrl &&
touched.externalUrl &&
typeof errors.externalUrl === 'string' && (
<div className="error">{errors.externalUrl}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="syncEnabled" className="checkbox-label">
{intl.formatMessage(messages.syncEnabled)}
<span className="label-tip">
{intl.formatMessage(messages.syncEnabledHelp)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="syncEnabled"
name="syncEnabled"
/>
</div>
</div>
<div className="form-row">
<label htmlFor="enableSearch" className="checkbox-label">
{intl.formatMessage(messages.enableSearch)}
<span className="label-tip">
{intl.formatMessage(messages.enableSearchHelp)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="enableSearch"
name="enableSearch"
/>
</div>
</div>
<div className="form-row">
<label htmlFor="tagRequests" className="checkbox-label">
{intl.formatMessage(messages.tagRequests)}
<span className="label-tip">
{intl.formatMessage(messages.tagRequestsInfo)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="tagRequests"
name="tagRequests"
/>
</div>
</div>
</div>
</Modal>
);
}}
</Formik>
</Transition>
);
};
export default LidarrModal;

View File

@@ -1,3 +1,4 @@
import LidarrLogo from '@app/assets/services/lidarr.svg';
import RadarrLogo from '@app/assets/services/radarr.svg';
import SonarrLogo from '@app/assets/services/sonarr.svg';
import Alert from '@app/components/Common/Alert';
@@ -6,6 +7,7 @@ import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
import PageTitle from '@app/components/Common/PageTitle';
import LidarrModal from '@app/components/Settings/LidarrModal';
import OverrideRuleModal from '@app/components/Settings/OverrideRule/OverrideRuleModal';
import OverrideRuleTiles from '@app/components/Settings/OverrideRule/OverrideRuleTiles';
import RadarrModal from '@app/components/Settings/RadarrModal';
@@ -16,7 +18,11 @@ import { Transition } from '@headlessui/react';
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
import type OverrideRule from '@server/entity/OverrideRule';
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import type {
LidarrSettings,
RadarrSettings,
SonarrSettings,
} from '@server/lib/settings';
import axios from 'axios';
import { Fragment, useState } from 'react';
import { useIntl } from 'react-intl';
@@ -26,8 +32,11 @@ const messages = defineMessages('components.Settings', {
services: 'Services',
radarrsettings: 'Radarr Settings',
sonarrsettings: 'Sonarr Settings',
serviceSettingsDescription:
lidarrsettings: 'Lidarr Settings',
videoServiceSettingsDescription:
'Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only two of them can be marked as defaults (one non-4K and one 4K). Administrators are able to override the server used to process new requests prior to approval.',
musicServiceSettingsDescription:
'Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only one of them can be marked as default. Administrators are able to override the server used to process new requests prior to approval.',
deleteserverconfirm: 'Are you sure you want to delete this server?',
ssl: 'SSL',
default: 'Default',
@@ -37,6 +46,7 @@ const messages = defineMessages('components.Settings', {
activeProfile: 'Active Profile',
addradarr: 'Add Radarr Server',
addsonarr: 'Add Sonarr Server',
addlidarr: 'Add Lidarr Server',
noDefaultServer:
'At least one {serverType} server must be marked as default in order for {mediaType} requests to be processed.',
noDefaultNon4kServer:
@@ -45,6 +55,7 @@ const messages = defineMessages('components.Settings', {
'A 4K {serverType} server must be marked as default in order to enable users to submit 4K {mediaType} requests.',
mediaTypeMovie: 'movie',
mediaTypeSeries: 'series',
mediaTypeMusic: 'music',
deleteServer: 'Delete {serverType} Server',
overrideRules: 'Override Rules',
overrideRulesDescription:
@@ -62,6 +73,7 @@ interface ServerInstanceProps {
externalUrl?: string;
profileName: string;
isSonarr?: boolean;
isLidarr?: boolean;
onEdit: () => void;
onDelete: () => void;
}
@@ -93,6 +105,13 @@ export type SonarrTestResponse = DVRTestResponse & {
| null;
};
export type LidarrTestResponse = DVRTestResponse & {
metadataProfiles: {
id: number;
name: string;
}[];
};
const ServerInstance = ({
name,
hostname,
@@ -102,6 +121,7 @@ const ServerInstance = ({
isDefault = false,
isSSL = false,
isSonarr = false,
isLidarr = false,
externalUrl,
onEdit,
onDelete,
@@ -172,6 +192,8 @@ const ServerInstance = ({
>
{isSonarr ? (
<SonarrLogo className="h-10 w-10 flex-shrink-0" />
) : isLidarr ? (
<LidarrLogo className="h-10 w-10 flex-shrink-0" />
) : (
<RadarrLogo className="h-10 w-10 flex-shrink-0" />
)}
@@ -215,6 +237,11 @@ const SettingsServices = () => {
error: sonarrError,
mutate: revalidateSonarr,
} = useSWR<SonarrSettings[]>('/api/v1/settings/sonarr');
const {
data: lidarrData,
error: lidarrError,
mutate: revalidateLidarr,
} = useSWR<LidarrSettings[]>('/api/v1/settings/lidarr');
const { data: rules, mutate: revalidate } =
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
const [editRadarrModal, setEditRadarrModal] = useState<{
@@ -231,9 +258,16 @@ const SettingsServices = () => {
open: false,
sonarr: null,
});
const [editLidarrModal, setEditLidarrModal] = useState<{
open: boolean;
lidarr: LidarrSettings | null;
}>({
open: false,
lidarr: null,
});
const [deleteServerModal, setDeleteServerModal] = useState<{
open: boolean;
type: 'radarr' | 'sonarr';
type: 'radarr' | 'sonarr' | 'lidarr';
serverId: number | null;
}>({
open: false,
@@ -255,6 +289,7 @@ const SettingsServices = () => {
setDeleteServerModal({ open: false, serverId: null, type: 'radarr' });
revalidateRadarr();
revalidateSonarr();
revalidateLidarr();
mutate('/api/v1/settings/public');
};
@@ -271,7 +306,7 @@ const SettingsServices = () => {
{intl.formatMessage(messages.radarrsettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.serviceSettingsDescription, {
{intl.formatMessage(messages.videoServiceSettingsDescription, {
serverType: 'Radarr',
})}
</p>
@@ -304,6 +339,17 @@ const SettingsServices = () => {
}}
/>
)}
{editLidarrModal.open && (
<LidarrModal
lidarr={editLidarrModal.lidarr}
onClose={() => setEditLidarrModal({ open: false, lidarr: null })}
onSave={() => {
revalidateLidarr();
mutate('/api/v1/settings/public');
setEditLidarrModal({ open: false, lidarr: null });
}}
/>
)}
<Transition
as={Fragment}
show={deleteServerModal.open}
@@ -327,7 +373,11 @@ const SettingsServices = () => {
}
title={intl.formatMessage(messages.deleteServer, {
serverType:
deleteServerModal.type === 'radarr' ? 'Radarr' : 'Sonarr',
deleteServerModal.type === 'radarr'
? 'Radarr'
: deleteServerModal.type === 'sonarr'
? 'Sonarr'
: 'Lidarr',
})}
>
{intl.formatMessage(messages.deleteserverconfirm)}
@@ -416,7 +466,7 @@ const SettingsServices = () => {
{intl.formatMessage(messages.sonarrsettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.serviceSettingsDescription, {
{intl.formatMessage(messages.videoServiceSettingsDescription, {
serverType: 'Sonarr',
})}
</p>
@@ -499,6 +549,68 @@ const SettingsServices = () => {
</>
)}
</div>
<div className="mb-6 mt-10">
<h3 className="heading">
{intl.formatMessage(messages.lidarrsettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.musicServiceSettingsDescription, {
serverType: 'Lidarr',
})}
</p>
</div>
<div className="section">
{!lidarrData && !lidarrError && <LoadingSpinner />}
{lidarrData && !lidarrError && (
<>
{lidarrData.length > 0 &&
(!lidarrData.some((lidarr) => lidarr.isDefault) ? (
<Alert
title={intl.formatMessage(messages.noDefaultServer, {
serverType: 'Lidarr',
mediaType: intl.formatMessage(messages.mediaTypeMusic),
})}
/>
) : null)}
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
{lidarrData.map((lidarr) => (
<ServerInstance
key={`lidarr-config-${lidarr.id}`}
name={lidarr.name}
hostname={lidarr.hostname}
port={lidarr.port}
profileName={lidarr.activeProfileName}
isSSL={lidarr.useSsl}
isLidarr={true}
isDefault={lidarr.isDefault}
externalUrl={lidarr.externalUrl}
onEdit={() => setEditLidarrModal({ open: true, lidarr })}
onDelete={() =>
setDeleteServerModal({
open: true,
serverId: lidarr.id,
type: 'lidarr',
})
}
/>
))}
<li className="col-span-1 h-32 rounded-lg border-2 border-dashed border-gray-400 shadow sm:h-44">
<div className="flex h-full w-full items-center justify-center">
<Button
buttonType="ghost"
onClick={() =>
setEditLidarrModal({ open: true, lidarr: null })
}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addlidarr)}</span>
</Button>
</div>
</li>
</ul>
</>
)}
</div>
<div className="mb-6 mt-10">
<h3 className="heading">
{intl.formatMessage(messages.overrideRules)}

View File

@@ -624,6 +624,56 @@
"components.Selector.showless": "Show Less",
"components.Selector.showmore": "Show More",
"components.Selector.starttyping": "Starting typing to search.",
"components.Settings.LidarrModal.add": "Add Server",
"components.Settings.LidarrModal.apiKey": "API Key",
"components.Settings.LidarrModal.apiKeyHelp": "Find it in Lidarr: Settings > General > Security > API Key",
"components.Settings.LidarrModal.baseUrl": "URL Base",
"components.Settings.LidarrModal.baseUrlHelp": "If you set a URL Base in Lidarr (Settings > General > Host), enter it here (e.g. /lidarr). Leave blank otherwise.",
"components.Settings.LidarrModal.createlidarr": "Add New Lidarr Server",
"components.Settings.LidarrModal.defaultserver": "Default Server",
"components.Settings.LidarrModal.editlidarr": "Edit Lidarr Server",
"components.Settings.LidarrModal.enableSearch": "Enable Automatic Search",
"components.Settings.LidarrModal.enableSearchHelp": "Automatically trigger a search in Lidarr when a request is approved.",
"components.Settings.LidarrModal.externalUrl": "External URL",
"components.Settings.LidarrModal.externalUrlHelp": "For clickable links on media pages when the hostname is not reachable from outside your network.",
"components.Settings.LidarrModal.hostname": "Hostname or IP Address",
"components.Settings.LidarrModal.loadingTags": "Loading tags…",
"components.Settings.LidarrModal.loadingmetadataprofiles": "Loading metadata profiles…",
"components.Settings.LidarrModal.loadingprofiles": "Loading quality profiles…",
"components.Settings.LidarrModal.loadingrootfolders": "Loading root folders…",
"components.Settings.LidarrModal.metadataprofile": "Metadata Profile",
"components.Settings.LidarrModal.notagoptions": "No tags.",
"components.Settings.LidarrModal.port": "Port",
"components.Settings.LidarrModal.qualityprofile": "Quality Profile",
"components.Settings.LidarrModal.rootfolder": "Root Folder",
"components.Settings.LidarrModal.selectMetadataProfile": "Select metadata profile",
"components.Settings.LidarrModal.selectQualityProfile": "Select quality profile",
"components.Settings.LidarrModal.selectRootFolder": "Select root folder",
"components.Settings.LidarrModal.selecttags": "Select tags",
"components.Settings.LidarrModal.servername": "Server Name",
"components.Settings.LidarrModal.ssl": "Use SSL",
"components.Settings.LidarrModal.syncEnabled": "Enable Scan",
"components.Settings.LidarrModal.syncEnabledHelp": "Scan Lidarr for existing media and request status so users cannot request content already available.",
"components.Settings.LidarrModal.tagRequests": "Tag Requests",
"components.Settings.LidarrModal.tagRequestsInfo": "Automatically add an additional tag with the requester's user ID & display name",
"components.Settings.LidarrModal.tags": "Tags",
"components.Settings.LidarrModal.testFirstMetadataProfiles": "Test connection to load metadata profiles",
"components.Settings.LidarrModal.testFirstQualityProfiles": "Test connection to load quality profiles",
"components.Settings.LidarrModal.testFirstRootFolders": "Test connection to load root folders",
"components.Settings.LidarrModal.testFirstTags": "Test connection to load tags",
"components.Settings.LidarrModal.toastLidarrTestFailure": "Failed to connect to Lidarr.",
"components.Settings.LidarrModal.toastLidarrTestSuccess": "Lidarr connection established successfully!",
"components.Settings.LidarrModal.validationApiKeyRequired": "You must provide an API key",
"components.Settings.LidarrModal.validationApplicationUrl": "You must provide a valid URL",
"components.Settings.LidarrModal.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
"components.Settings.LidarrModal.validationBaseUrlLeadingSlash": "Base URL must have a leading slash",
"components.Settings.LidarrModal.validationBaseUrlTrailingSlash": "Base URL must not end in a trailing slash",
"components.Settings.LidarrModal.validationHostnameRequired": "You must provide a valid hostname or IP address",
"components.Settings.LidarrModal.validationMetadataProfileRequired": "You must select a metadata profile",
"components.Settings.LidarrModal.validationNameRequired": "You must provide a server name",
"components.Settings.LidarrModal.validationPortRequired": "You must provide a valid port number",
"components.Settings.LidarrModal.validationProfileRequired": "You must select a quality profile",
"components.Settings.LidarrModal.validationRootFolderRequired": "You must select a root folder",
"components.Settings.Notifications.NotificationsGotify.agentenabled": "Enable Agent",
"components.Settings.Notifications.NotificationsGotify.gotifysettingsfailed": "Gotify notification settings failed to save.",
"components.Settings.Notifications.NotificationsGotify.gotifysettingssaved": "Gotify notification settings saved successfully!",
@@ -1150,6 +1200,7 @@
"components.Settings.SonarrModal.validationProfileRequired": "You must select a quality profile",
"components.Settings.SonarrModal.validationRootFolderRequired": "You must select a root folder",
"components.Settings.activeProfile": "Active Profile",
"components.Settings.addlidarr": "Add Lidarr Server",
"components.Settings.addradarr": "Add Radarr Server",
"components.Settings.address": "Address",
"components.Settings.addrule": "New Override Rule",
@@ -1199,11 +1250,13 @@
"components.Settings.jellyfinsettings": "{mediaServerName} Settings",
"components.Settings.jellyfinsettingsDescription": "Configure the settings for your {mediaServerName} server. {mediaServerName} scans your {mediaServerName} libraries to see what content is available.",
"components.Settings.librariesRemaining": "Libraries Remaining: {count}",
"components.Settings.lidarrsettings": "Lidarr Settings",
"components.Settings.manualscan": "Manual Library Scan",
"components.Settings.manualscanDescription": "Normally, this will only be run once every 24 hours. Seerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!",
"components.Settings.manualscanDescriptionJellyfin": "Normally, this will only be run once every 24 hours. Seerr will check your {mediaServerName} server's recently added more aggressively. If this is your first time configuring Seerr, a one-time full manual library scan is recommended!",
"components.Settings.manualscanJellyfin": "Manual Library Scan",
"components.Settings.mediaTypeMovie": "movie",
"components.Settings.mediaTypeMusic": "music",
"components.Settings.mediaTypeSeries": "series",
"components.Settings.menuAbout": "About",
"components.Settings.menuGeneralSettings": "General",
@@ -1220,6 +1273,7 @@
"components.Settings.metadataProviderSettings": "Metadata Providers",
"components.Settings.metadataSettings": "Settings for metadata provider",
"components.Settings.metadataSettingsSaved": "Metadata provider settings saved",
"components.Settings.musicServiceSettingsDescription": "Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only one of them can be marked as default. Administrators are able to override the server used to process new requests prior to approval.",
"components.Settings.no": "No",
"components.Settings.noDefault4kServer": "A 4K {serverType} server must be marked as default in order to enable users to submit 4K {mediaType} requests.",
"components.Settings.noDefaultNon4kServer": "If you only have a single {serverType} server for both non-4K and 4K content (or if you only download 4K content), your {serverType} server should <strong>NOT</strong> be designated as a 4K server.",
@@ -1257,7 +1311,6 @@
"components.Settings.serverpresetLoad": "Press the button to load available servers",
"components.Settings.serverpresetManualMessage": "Manual configuration",
"components.Settings.serverpresetRefreshing": "Retrieving servers…",
"components.Settings.serviceSettingsDescription": "Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only two of them can be marked as defaults (one non-4K and one 4K). Administrators are able to override the server used to process new requests prior to approval.",
"components.Settings.services": "Services",
"components.Settings.settingUpPlexDescription": "To set up Plex, you can either enter the details manually or select a server retrieved from <RegisterPlexTVLink>plex.tv</RegisterPlexTVLink>. Press the button to the right of the dropdown to fetch the list of available servers.",
"components.Settings.settings": "Settings",
@@ -1291,6 +1344,7 @@
"components.Settings.validationUrlBaseTrailingSlash": "URL base must not end in a trailing slash",
"components.Settings.validationUrlTrailingSlash": "URL must not end in a trailing slash",
"components.Settings.valueRequired": "You must provide a value.",
"components.Settings.videoServiceSettingsDescription": "Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only two of them can be marked as defaults (one non-4K and one 4K). Administrators are able to override the server used to process new requests prior to approval.",
"components.Settings.webAppUrl": "<WebAppLink>Web App</WebAppLink> URL",
"components.Settings.webAppUrlTip": "Optionally direct users to the web app on your server instead of the \"hosted\" web app",
"components.Settings.webhook": "Webhook",