feat(lidarr): "Added the front-end for the ability to add Lidarr Servers"

pull/3800/merge^2
Anatole Sot 4 months ago
parent 5ea66adfbf
commit 755b26e57f

@ -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.
servers:
- url: '{server}/api/v1'
variables:
@ -466,6 +466,61 @@ 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: 8989
apiKey:
type: string
example: 'exampleapikey'
useSsl:
type: boolean
example: false
baseUrl:
type: string
activeProfileId:
type: number
example: 1
activeProfileName:
type: string
example: 128kps
activeDirectory:
type: string
example: '/music/'
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
- isDefault
ServarrTag:
type: object
properties:
@ -2408,6 +2463,150 @@ 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: 7878
apiKey:
type: string
example: yourapikey
useSsl:
type: boolean
example: false
baseUrl:
type: string
required:
- hostname
- port
- apiKey
- useSsl
responses:
'200':
description: Succesfully 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 updated'
content:
application/json:
schema:
$ref: '#/components/schemas/LidarrSettings'
/settings/lidarr/{lidarrId}/profiles:
get:
summary: Get available Lidarr profiles
description: Returns a list of profiles available on the Lidarr server instance in a JSON array.
tags:
- settings
parameters:
- in: path
name: lidarrId
required: true
schema:
type: integer
description: Lidarr instance ID
responses:
'200':
description: Returned list of profiles
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ServiceProfile'
/settings/public:
get:
summary: Get public settings
@ -5162,7 +5361,7 @@ paths:
post:
summary: Retry failed request
description: |
Retries a request by resending requests to Sonarr or Radarr.
Retries a request by resending requests to Sonarr, Radarr or Lidarr.
Requires the `MANAGE_REQUESTS` permission or `ADMIN`.
tags:
@ -5948,6 +6147,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

@ -4,7 +4,7 @@ import type { LanguageProfile } from '@server/api/servarr/sonarr';
export interface ServiceCommonServer {
id: number;
name: string;
is4k: boolean;
is4k?: boolean;
isDefault: boolean;
activeProfileId: number;
activeDirectory: string;
@ -12,7 +12,7 @@ export interface ServiceCommonServer {
activeAnimeProfileId?: number;
activeAnimeDirectory?: string;
activeAnimeLanguageProfileId?: number;
activeTags: number[];
activeTags: number[] | string[];
activeAnimeTags?: number[];
}

@ -83,6 +83,7 @@ export interface SonarrSettings extends DVRSettings {
animeTags?: number[];
enableSeasonFolders: boolean;
}
export type LidarrSettings = ArrSettings;
interface Quota {
quotaLimit?: number;

@ -1,5 +1,6 @@
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import LidarrAPI from '@server/api/servarr/lidarr';
import TheMovieDb from '@server/api/themoviedb';
import type {
ServiceCommonServer,
@ -209,4 +210,70 @@ 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,
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'),
});
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,
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);
}
);
export default serviceRoutes;

@ -35,12 +35,14 @@ import { URL } from 'url';
import notificationRoutes from './notifications';
import radarrRoutes from './radarr';
import sonarrRoutes from './sonarr';
import lidarrRoutes from './lidarr';
const settingsRoutes = Router();
settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes);
settingsRoutes.use('/lidarr', lidarrRoutes)
settingsRoutes.use('/discover', discoverSettingRoutes);
const filteredMainSettings = (

@ -0,0 +1,135 @@
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('/', (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 for the same type first
settings.lidarr = [...settings.lidarr, newLidarr];
settings.save();
return res.status(201).json(newLidarr);
});
lidarrRoutes.post<
undefined,
Record<string, unknown>,
LidarrSettings & { tagLabel?: string }
>('/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)
.catch(() => req.body.baseUrl);
const profiles = await lidarr.getProfiles();
const folders = await lidarr.getRootFolders();
const tags = await lidarr.getTags();
return res.status(200).json({
profiles,
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 }, LidarrSettings, LidarrSettings>(
'/:id',
(req, res, next) => {
const settings = getSettings();
const lidarrIndex = settings.lidarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (lidarrIndex === -1) {
return next({ status: '404', message: 'Settings instance not found' });
}
// If we are setting this as the default, clear any previous defaults for the same type first
settings.lidarr[lidarrIndex] = {
...req.body,
id: Number(req.params.id),
} as LidarrSettings;
settings.save();
return res.status(200).json(settings.lidarr[lidarrIndex]);
}
);
lidarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => {
const settings = getSettings();
const lidarrSettings = settings.lidarr.find(
(r) => r.id === Number(req.params.id)
);
if (!lidarrSettings) {
return next({ status: '404', message: 'Settings instance not found' });
}
const lidarr = new LidarrAPI({
apiKey: lidarrSettings.apiKey,
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
});
const profiles = await lidarr.getProfiles();
return res.status(200).json(
profiles.map((profile) => ({
id: profile.id,
name: profile.name,
}))
);
});
lidarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => {
const settings = getSettings();
const lidarrIndex = settings.lidarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (lidarrIndex === -1) {
return next({ status: '404', message: 'Settings instance not found' });
}
const removed = settings.lidarr.splice(lidarrIndex, 1);
settings.save();
return res.status(200).json(removed[0]);
});
export default lidarrRoutes;

@ -0,0 +1,9 @@
<svg viewBox="0 0 216.7 216.9" xmlns="http://www.w3.org/2000/svg"><path d="M216.7 108.45c0 29.833-10.533 55.4-31.6 76.7-.7.833-1.483 1.6-2.35 2.3-3.466 3.4-7.133 6.484-11 9.25-18.267 13.467-39.367 20.2-63.3 20.2-23.967 0-45.033-6.733-63.2-20.2-4.8-3.4-9.3-7.25-13.5-11.55-16.367-16.266-26.417-35.167-30.15-56.7-.733-4.2-1.217-8.467-1.45-12.8-.1-2.4-.15-4.8-.15-7.2 0-2.533.05-4.95.15-7.25 0-.233.066-.467.2-.7 1.567-26.6 12.033-49.583 31.4-68.95C53.05 10.517 78.617 0 108.45 0c29.933 0 55.484 10.517 76.65 31.55 21.067 21.433 31.6 47.067 31.6 76.9z" clip-rule="evenodd" fill="#EEE" fill-rule="evenodd"></path><path d="M194.65 42.5l-22.4 22.4C159.152 77.998 158 89.4 158 109.5c0 17.934 2.852 34.352 16.2 47.7 9.746 9.746 19 18.95 19 18.95-2.5 3.067-5.2 6.067-8.1 9-.7.833-1.483 1.6-2.35 2.3-2.533 2.5-5.167 4.817-7.9 6.95l-17.55-17.55c-15.598-15.6-27.996-17.1-48.6-17.1-19.77 0-33.223 1.822-47.7 16.3-8.647 8.647-18.55 18.6-18.55 18.6-3.767-2.867-7.333-6.034-10.7-9.5-2.8-2.8-5.417-5.667-7.85-8.6 0 0 9.798-9.848 19.15-19.2 13.852-13.853 16.1-29.916 16.1-47.85 0-17.5-2.874-33.823-15.6-46.55-8.835-8.836-21.05-21-21.05-21 2.833-3.6 5.917-7.067 9.25-10.4 2.934-2.867 5.934-5.55 9-8.05L61.1 43.85C74.102 56.852 90.767 60.2 108.7 60.2c18.467 0 35.077-3.577 48.6-17.1 8.32-8.32 19.3-19.25 19.3-19.25 2.9 2.367 5.733 4.933 8.5 7.7 3.467 3.533 6.65 7.183 9.55 10.95z" clip-rule="evenodd" fill="#3A3F51" fill-rule="evenodd"></path><g clip-rule="evenodd" style="
fill: #FFEB3B;
"><path d="M78.7 114c-.2-1.167-.332-2.35-.4-3.55-.032-.667-.05-1.333-.05-2 0-.7.018-1.367.05-2 0-.067.018-.133.05-.2.435-7.367 3.334-13.733 8.7-19.1 5.9-5.833 12.984-8.75 21.25-8.75 8.3 0 15.384 2.917 21.25 8.75 5.834 5.934 8.75 13.033 8.75 21.3 0 8.267-2.916 15.35-8.75 21.25-.2.233-.416.45-.65.65-.966.933-1.982 1.783-3.05 2.55-5.065 3.733-10.916 5.6-17.55 5.6s-12.466-1.866-17.5-5.6c-1.332-.934-2.582-2-3.75-3.2-4.532-4.5-7.316-9.734-8.35-15.7z" fill="#0CF" fill-rule="evenodd" style="
fill: #FFEB3B;
"></path><path d="M157.8 59.75l-15 14.65M30.785 32.526L71.65 73.25m84.6 84.25l27.808 28.78m1.855-153.894L157.8 59.75m-125.45 126l27.35-27.4" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="2" style="
stroke: #FFEB3B;
"></path><path d="M157.8 59.75l-16.95 17.2M58.97 60.604l17.2 17.15M59.623 158.43l16.75-17.4m61.928-1.396l18.028 17.945" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="7" style="
stroke: #FFEB3B;
"></path></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@ -42,13 +42,13 @@ export type RequestOverrides = {
server?: number;
profile?: number;
folder?: string;
tags?: number[];
tags?: number[] | string[];
language?: number;
user?: User;
};
interface AdvancedRequesterProps {
type: 'movie' | 'tv';
type: 'movie' | 'tv' | 'music';
is4k: boolean;
isAnime?: boolean;
defaultOverrides?: RequestOverrides;
@ -67,14 +67,14 @@ const AdvancedRequester = ({
const intl = useIntl();
const { user: currentUser, hasPermission: currentHasPermission } = useUser();
const { data, error } = useSWR<ServiceCommonServer[]>(
`/api/v1/service/${type === 'movie' ? 'radarr' : 'sonarr'}`,
{
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
revalidateOnMount: true,
}
);
`/api/v1/service/${type === 'movie' ? 'radarr' : 'sonarr'}`,
{
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
revalidateOnMount: true,
}
);
const [selectedServer, setSelectedServer] = useState<number | null>(
defaultOverrides?.server !== undefined && defaultOverrides?.server >= 0
? defaultOverrides?.server
@ -91,7 +91,7 @@ const AdvancedRequester = ({
defaultOverrides?.language ?? -1
);
const [selectedTags, setSelectedTags] = useState<number[]>(
const [selectedTags, setSelectedTags] = useState<number[] | string[]>(
defaultOverrides?.tags ?? []
);
@ -99,7 +99,7 @@ const AdvancedRequester = ({
useSWR<ServiceCommonServerWithDetails>(
selectedServer !== null
? `/api/v1/service/${
type === 'movie' ? 'radarr' : 'sonarr'
type === 'movie' ? 'radarr' : (type === 'music' ? 'lidarr' : 'sonarr')
}/${selectedServer}`
: null,
{

@ -0,0 +1,702 @@
import Modal from '@app/components/Common/Modal';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import globalMessages from '@app/i18n/globalMessages';
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 { defineMessages, useIntl } from 'react-intl';
import type { OnChangeValue } from 'react-select';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
type OptionType = {
value: number;
label: string;
};
const messages = defineMessages({
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',
validationLanguageProfileRequired: 'You must select a language 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',
languageprofile: 'Language Profile',
rootfolder: 'Root Folder',
selectQualityProfile: 'Select quality profile',
selectRootFolder: 'Select root folder',
selectLanguageProfile: 'Select language profile',
loadingprofiles: 'Loading quality profiles…',
testFirstQualityProfiles: 'Test connection to load quality profiles',
loadingrootfolders: 'Loading root folders…',
testFirstRootFolders: 'Test connection to load root folders',
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',
});
interface TestResponse {
profiles: {
id: number;
name: string;
}[];
rootFolders: {
id: number;
path: string;
}[];
languageProfiles: {
id: number;
name: string;
}[];
tags: {
id: number;
label: string;
}[];
urlBase?: string;
}
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<TestResponse>({
profiles: [],
rootFolders: [],
languageProfiles: [],
tags: [],
});
const LidarrSettingsSchema = Yup.object().shape({
name: Yup.string().required(
intl.formatMessage(messages.validationNameRequired)
),
hostname: Yup.string()
.required(intl.formatMessage(messages.validationHostnameRequired))
.matches(
/^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
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)
),
activeLanguageProfileId: Yup.number().required(
intl.formatMessage(messages.validationLanguageProfileRequired)
),
externalUrl: Yup.string()
.url(intl.formatMessage(messages.validationApplicationUrl))
.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<TestResponse>(
'/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 (e) {
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 ?? 8989,
ssl: lidarr?.useSsl ?? false,
apiKey: lidarr?.apiKey,
baseUrl: lidarr?.baseUrl,
activeProfileId: lidarr?.activeProfileId,
rootFolder: lidarr?.activeDirectory,
isDefault: lidarr?.isDefault ?? false,
tags: lidarr?.tags ?? [],
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 submission = {
name: values.name,
hostname: values.hostname,
port: Number(values.port),
apiKey: values.apiKey,
useSsl: values.ssl,
baseUrl: values.baseUrl,
activeProfileId: Number(values.activeProfileId),
activeProfileName: profileName,
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 (e) {
// 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"
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>
</label>
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
as="field"
id="apiKey"
name="apiKey"
autoComplete="one-time-code"
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)}
</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.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="activeLanguageProfileId"
className="text-label"
>
{intl.formatMessage(messages.languageprofile)}
<span className="label-required">*</span>
</label>
</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)}
</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)}
</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)}
</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,5 +1,6 @@
import RadarrLogo from '@app/assets/services/radarr.svg';
import SonarrLogo from '@app/assets/services/sonarr.svg';
import LidarrLogo from '@app/assets/services/lidarr.svg';
import Alert from '@app/components/Common/Alert';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
@ -8,10 +9,11 @@ import Modal from '@app/components/Common/Modal';
import PageTitle from '@app/components/Common/PageTitle';
import RadarrModal from '@app/components/Settings/RadarrModal';
import SonarrModal from '@app/components/Settings/SonarrModal';
import LidarrModal from '@app/components/Settings/LidarrModal';
import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react';
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import type { RadarrSettings, SonarrSettings, LidarrSettings } from '@server/lib/settings';
import axios from 'axios';
import { Fragment, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@ -21,8 +23,11 @@ const messages = defineMessages({
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',
@ -32,6 +37,7 @@ const messages = defineMessages({
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:
@ -40,6 +46,7 @@ const messages = defineMessages({
'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',
});
@ -53,6 +60,7 @@ interface ServerInstanceProps {
externalUrl?: string;
profileName: string;
isSonarr?: boolean;
isLidarr?: boolean;
onEdit: () => void;
onDelete: () => void;
}
@ -66,6 +74,7 @@ const ServerInstance = ({
isDefault = false,
isSSL = false,
isSonarr = false,
isLidarr = false,
externalUrl,
onEdit,
onDelete,
@ -127,6 +136,8 @@ const ServerInstance = ({
<a href={serviceUrl} className="opacity-50 hover:opacity-100">
{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" />
)}
@ -170,6 +181,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 [editRadarrModal, setEditRadarrModal] = useState<{
open: boolean;
radarr: RadarrSettings | null;
@ -184,9 +200,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,
@ -217,7 +240,7 @@ const SettingsServices = () => {
{intl.formatMessage(messages.radarrsettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.serviceSettingsDescription, {
{intl.formatMessage(messages.videoServiceSettingsDescription, {
serverType: 'Radarr',
})}
</p>
@ -244,6 +267,17 @@ const SettingsServices = () => {
}}
/>
)}
{editLidarrModal.open && (
<LidarrModal
sonarr={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}
@ -267,7 +301,7 @@ 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)}
@ -356,7 +390,7 @@ const SettingsServices = () => {
{intl.formatMessage(messages.sonarrsettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.serviceSettingsDescription, {
{intl.formatMessage(messages.videoServiceSettingsDescription, {
serverType: 'Sonarr',
})}
</p>
@ -439,6 +473,69 @@ const SettingsServices = () => {
</>
)}
</div>
<div className="mt-10 mb-6">
<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.mediaTypeSeries),
})}
/>
) : 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>
</>
);
};

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save