mirror of
https://github.com/seerr-team/seerr.git
synced 2026-06-15 11:59:11 -04:00
feat(music): add Lidarr server settings and connectivity (#3109)
This commit is contained in:
@@ -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!
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 → General → Security**.
|
||||
You can locate the required API keys in Radarr/Sonarr/Lidarr in **Settings → General → 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 → General → 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 → General → 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.
|
||||
|
||||
227
seerr-api.yml
227
seerr-api.yml
@@ -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
|
||||
|
||||
305
server/api/servarr/lidarr.ts
Normal file
305
server/api/servarr/lidarr.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
118
server/routes/settings/lidarr.ts
Normal file
118
server/routes/settings/lidarr.ts
Normal 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;
|
||||
1
src/assets/services/lidarr.svg
Normal file
1
src/assets/services/lidarr.svg
Normal 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 |
781
src/components/Settings/LidarrModal/index.tsx
Normal file
781
src/components/Settings/LidarrModal/index.tsx
Normal 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;
|
||||
@@ -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)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user