mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
740 lines
27 KiB
TypeScript
740 lines
27 KiB
TypeScript
import Badge from '@app/components/Common/Badge';
|
|
import Button from '@app/components/Common/Button';
|
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
|
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
|
import LibraryItem from '@app/components/Settings/LibraryItem';
|
|
import globalMessages from '@app/i18n/globalMessages';
|
|
import defineMessages from '@app/utils/defineMessages';
|
|
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
|
import { ApiErrorCode } from '@server/constants/error';
|
|
import type { JellyfinSettings } from '@server/lib/settings';
|
|
import { Field, Formik } from 'formik';
|
|
import getConfig from 'next/config';
|
|
import { useState } from 'react';
|
|
import { FormattedMessage, useIntl } from 'react-intl';
|
|
import { useToasts } from 'react-toast-notifications';
|
|
import useSWR from 'swr';
|
|
import * as Yup from 'yup';
|
|
|
|
const messages = defineMessages('components.Settings', {
|
|
jellyfinsettings: '{mediaServerName} Settings',
|
|
jellyfinsettingsDescription:
|
|
'Configure the settings for your {mediaServerName} server. {mediaServerName} scans your {mediaServerName} libraries to see what content is available.',
|
|
timeout: 'Timeout',
|
|
save: 'Save Changes',
|
|
saving: 'Saving…',
|
|
jellyfinlibraries: '{mediaServerName} Libraries',
|
|
jellyfinlibrariesDescription:
|
|
'The libraries {mediaServerName} scans for titles. Click the button below if no libraries are listed.',
|
|
jellyfinSettingsFailure:
|
|
'Something went wrong while saving {mediaServerName} settings.',
|
|
jellyfinSettingsSuccess: '{mediaServerName} settings saved successfully!',
|
|
jellyfinSettings: '{mediaServerName} Settings',
|
|
jellyfinSettingsDescription:
|
|
'Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page. You can also change the Jellyfin API key, which was automatically generated previously.',
|
|
externalUrl: 'External URL',
|
|
hostname: 'Hostname or IP Address',
|
|
port: 'Port',
|
|
enablessl: 'Use SSL',
|
|
urlBase: 'URL Base',
|
|
jellyfinForgotPasswordUrl: 'Forgot Password URL',
|
|
apiKey: 'API key',
|
|
jellyfinSyncFailedNoLibrariesFound: 'No libraries were found',
|
|
jellyfinSyncFailedAutomaticGroupedFolders:
|
|
'Custom authentication with Automatic Library Grouping not supported',
|
|
jellyfinSyncFailedGenericError:
|
|
'Something went wrong while syncing libraries',
|
|
invalidurlerror: 'Unable to connect to {mediaServerName} server.',
|
|
syncing: 'Syncing',
|
|
syncJellyfin: 'Sync Libraries',
|
|
manualscanJellyfin: 'Manual Library Scan',
|
|
manualscanDescriptionJellyfin:
|
|
"Normally, this will only be run once every 24 hours. Jellyseerr will check your {mediaServerName} server's recently added more aggressively. If this is your first time configuring Jellyseerr, a one-time full manual library scan is recommended!",
|
|
notrunning: 'Not Running',
|
|
currentlibrary: 'Current Library: {name}',
|
|
librariesRemaining: 'Libraries Remaining: {count}',
|
|
startscan: 'Start Scan',
|
|
cancelscan: 'Cancel Scan',
|
|
validationUrl: 'You must provide a valid URL',
|
|
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
|
validationPortRequired: 'You must provide a valid port number',
|
|
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
|
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
|
|
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
|
|
});
|
|
|
|
interface Library {
|
|
id: string;
|
|
name: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
interface SyncStatus {
|
|
running: boolean;
|
|
progress: number;
|
|
total: number;
|
|
currentLibrary?: Library;
|
|
libraries: Library[];
|
|
}
|
|
|
|
interface SettingsJellyfinProps {
|
|
showAdvancedSettings?: boolean;
|
|
onComplete?: () => void;
|
|
}
|
|
|
|
const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|
onComplete,
|
|
showAdvancedSettings,
|
|
}) => {
|
|
const [isSyncing, setIsSyncing] = useState(false);
|
|
const toasts = useToasts();
|
|
|
|
const {
|
|
data,
|
|
error,
|
|
mutate: revalidate,
|
|
} = useSWR<JellyfinSettings>('/api/v1/settings/jellyfin');
|
|
const { data: dataSync, mutate: revalidateSync } = useSWR<SyncStatus>(
|
|
'/api/v1/settings/jellyfin/sync',
|
|
{
|
|
refreshInterval: 1000,
|
|
}
|
|
);
|
|
const intl = useIntl();
|
|
const { addToast } = useToasts();
|
|
const { publicRuntimeConfig } = getConfig();
|
|
|
|
const JellyfinSettingsSchema = Yup.object().shape({
|
|
hostname: Yup.string()
|
|
.nullable()
|
|
.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().when(['hostname'], {
|
|
is: (value: unknown) => !!value,
|
|
then: Yup.number()
|
|
.typeError(intl.formatMessage(messages.validationPortRequired))
|
|
.nullable()
|
|
.required(intl.formatMessage(messages.validationPortRequired)),
|
|
otherwise: Yup.number()
|
|
.typeError(intl.formatMessage(messages.validationPortRequired))
|
|
.nullable(),
|
|
}),
|
|
urlBase: Yup.string()
|
|
.test(
|
|
'leading-slash',
|
|
intl.formatMessage(messages.validationUrlBaseLeadingSlash),
|
|
(value) => !value || value.startsWith('/')
|
|
)
|
|
.test(
|
|
'trailing-slash',
|
|
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
|
|
(value) => !value || !value.endsWith('/')
|
|
),
|
|
jellyfinExternalUrl: Yup.string()
|
|
.nullable()
|
|
.url(intl.formatMessage(messages.validationUrl))
|
|
.test(
|
|
'no-trailing-slash',
|
|
intl.formatMessage(messages.validationUrlTrailingSlash),
|
|
(value) => !value || !value.endsWith('/')
|
|
),
|
|
jellyfinForgotPasswordUrl: Yup.string()
|
|
.nullable()
|
|
.url(intl.formatMessage(messages.validationUrl))
|
|
.test(
|
|
'no-trailing-slash',
|
|
intl.formatMessage(messages.validationUrlTrailingSlash),
|
|
(value) => !value || !value.endsWith('/')
|
|
),
|
|
});
|
|
|
|
const activeLibraries =
|
|
data?.libraries
|
|
.filter((library) => library.enabled)
|
|
.map((library) => library.id) ?? [];
|
|
|
|
const syncLibraries = async () => {
|
|
setIsSyncing(true);
|
|
|
|
const params: { sync: boolean; enable?: string } = {
|
|
sync: true,
|
|
};
|
|
|
|
if (activeLibraries.length > 0) {
|
|
params.enable = activeLibraries.join(',');
|
|
}
|
|
|
|
try {
|
|
const searchParams = new URLSearchParams({
|
|
sync: params.sync ? 'true' : 'false',
|
|
...(params.enable ? { enable: params.enable } : {}),
|
|
});
|
|
const res = await fetch(
|
|
`/api/v1/settings/jellyfin/library?${searchParams.toString()}`
|
|
);
|
|
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
|
setIsSyncing(false);
|
|
revalidate();
|
|
} catch (e) {
|
|
let errorData;
|
|
try {
|
|
errorData = await e.cause?.text();
|
|
errorData = JSON.parse(errorData);
|
|
} catch {
|
|
/* empty */
|
|
}
|
|
if (errorData?.message === 'SYNC_ERROR_GROUPED_FOLDERS') {
|
|
toasts.addToast(
|
|
intl.formatMessage(
|
|
messages.jellyfinSyncFailedAutomaticGroupedFolders
|
|
),
|
|
{
|
|
autoDismiss: true,
|
|
appearance: 'warning',
|
|
}
|
|
);
|
|
} else if (errorData?.message === 'SYNC_ERROR_NO_LIBRARIES') {
|
|
toasts.addToast(
|
|
intl.formatMessage(messages.jellyfinSyncFailedNoLibrariesFound),
|
|
{
|
|
autoDismiss: true,
|
|
appearance: 'error',
|
|
}
|
|
);
|
|
} else {
|
|
toasts.addToast(
|
|
intl.formatMessage(messages.jellyfinSyncFailedGenericError),
|
|
{
|
|
autoDismiss: true,
|
|
appearance: 'error',
|
|
}
|
|
);
|
|
}
|
|
setIsSyncing(false);
|
|
revalidate();
|
|
}
|
|
};
|
|
|
|
const startScan = async () => {
|
|
const res = await fetch('/api/v1/settings/jellyfin/sync', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
start: true,
|
|
}),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
|
|
revalidateSync();
|
|
};
|
|
|
|
const cancelScan = async () => {
|
|
const res = await fetch('/api/v1/settings/jellyfin/sync', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
cancel: true,
|
|
}),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
|
|
revalidateSync();
|
|
};
|
|
|
|
const toggleLibrary = async (libraryId: string) => {
|
|
setIsSyncing(true);
|
|
if (activeLibraries.includes(libraryId)) {
|
|
const params: { enable?: string } = {};
|
|
|
|
if (activeLibraries.length > 1) {
|
|
params.enable = activeLibraries
|
|
.filter((id) => id !== libraryId)
|
|
.join(',');
|
|
}
|
|
|
|
const searchParams = new URLSearchParams(params.enable ? params : {});
|
|
const res = await fetch(
|
|
`/api/v1/settings/jellyfin/library?${searchParams.toString()}`
|
|
);
|
|
if (!res.ok) throw new Error();
|
|
} else {
|
|
const searchParams = new URLSearchParams({
|
|
enable: [...activeLibraries, libraryId].join(','),
|
|
});
|
|
const res = await fetch(
|
|
`/api/v1/settings/jellyfin/library?${searchParams.toString()}`
|
|
);
|
|
if (!res.ok) throw new Error();
|
|
}
|
|
if (onComplete) {
|
|
onComplete();
|
|
}
|
|
setIsSyncing(false);
|
|
revalidate();
|
|
};
|
|
|
|
if (!data && !error) {
|
|
return <LoadingSpinner />;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="mb-6">
|
|
<h3 className="heading">
|
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
|
? intl.formatMessage(messages.jellyfinlibraries, {
|
|
mediaServerName: 'Emby',
|
|
})
|
|
: intl.formatMessage(messages.jellyfinlibraries, {
|
|
mediaServerName: 'Jellyfin',
|
|
})}
|
|
</h3>
|
|
<p className="description">
|
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
|
? intl.formatMessage(messages.jellyfinlibrariesDescription, {
|
|
mediaServerName: 'Emby',
|
|
})
|
|
: intl.formatMessage(messages.jellyfinlibrariesDescription, {
|
|
mediaServerName: 'Jellyfin',
|
|
})}
|
|
</p>
|
|
</div>
|
|
<div className="section">
|
|
<Button onClick={() => syncLibraries()} disabled={isSyncing}>
|
|
<svg
|
|
className={`${isSyncing ? 'animate-spin' : ''} mr-1 h-5 w-5`}
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
{isSyncing
|
|
? intl.formatMessage(messages.syncing)
|
|
: intl.formatMessage(messages.syncJellyfin)}
|
|
</Button>
|
|
<ul className="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2 sm:gap-6 lg:grid-cols-4">
|
|
{data?.libraries.map((library) => (
|
|
<LibraryItem
|
|
name={library.name}
|
|
isEnabled={library.enabled}
|
|
key={`setting-library-${library.id}`}
|
|
onToggle={() => toggleLibrary(library.id)}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<div className="mt-10 mb-6">
|
|
<h3 className="heading">
|
|
<FormattedMessage {...messages.manualscanJellyfin} />
|
|
</h3>
|
|
<p className="description">
|
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
|
? intl.formatMessage(messages.manualscanDescriptionJellyfin, {
|
|
mediaServerName: 'Emby',
|
|
})
|
|
: intl.formatMessage(messages.manualscanDescriptionJellyfin, {
|
|
mediaServerName: 'Jellyfin',
|
|
})}
|
|
</p>
|
|
</div>
|
|
<div className="section">
|
|
<div className="rounded-md bg-gray-800 p-4">
|
|
<div className="relative mb-6 h-8 w-full overflow-hidden rounded-full bg-gray-600">
|
|
{dataSync?.running && (
|
|
<div
|
|
className="h-8 bg-indigo-600 transition-all duration-200 ease-in-out"
|
|
style={{
|
|
width: `${Math.round(
|
|
(dataSync.progress / dataSync.total) * 100
|
|
)}%`,
|
|
}}
|
|
/>
|
|
)}
|
|
<div className="absolute inset-0 flex h-8 w-full items-center justify-center text-sm">
|
|
<span>
|
|
{dataSync?.running
|
|
? `${dataSync.progress} of ${dataSync.total}`
|
|
: 'Not running'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex w-full flex-col sm:flex-row">
|
|
{dataSync?.running && (
|
|
<>
|
|
{dataSync.currentLibrary && (
|
|
<div className="mb-2 mr-0 flex items-center sm:mb-0 sm:mr-2">
|
|
<Badge>
|
|
<FormattedMessage
|
|
{...messages.currentlibrary}
|
|
values={{ name: dataSync.currentLibrary.name }}
|
|
/>
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center">
|
|
<Badge badgeType="warning">
|
|
<FormattedMessage
|
|
{...messages.librariesRemaining}
|
|
values={{
|
|
count: dataSync.currentLibrary
|
|
? dataSync.libraries.slice(
|
|
dataSync.libraries.findIndex(
|
|
(library) =>
|
|
library.id === dataSync.currentLibrary?.id
|
|
) + 1
|
|
).length
|
|
: 0,
|
|
}}
|
|
/>
|
|
</Badge>
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className="flex-1 text-right">
|
|
{!dataSync?.running && (
|
|
<Button buttonType="warning" onClick={() => startScan()}>
|
|
<svg
|
|
className="mr-1 h-5 w-5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
/>
|
|
</svg>
|
|
<FormattedMessage {...messages.startscan} />
|
|
</Button>
|
|
)}
|
|
|
|
{dataSync?.running && (
|
|
<Button buttonType="danger" onClick={() => cancelScan()}>
|
|
<svg
|
|
className="mr-1 h-5 w-5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
<FormattedMessage {...messages.cancelscan} />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-10 mb-6">
|
|
<h3 className="heading">
|
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
|
? intl.formatMessage(messages.jellyfinSettings, {
|
|
mediaServerName: 'Emby',
|
|
})
|
|
: intl.formatMessage(messages.jellyfinSettings, {
|
|
mediaServerName: 'Jellyfin',
|
|
})}
|
|
</h3>
|
|
<p className="description">
|
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
|
? intl.formatMessage(messages.jellyfinSettingsDescription, {
|
|
mediaServerName: 'Emby',
|
|
})
|
|
: intl.formatMessage(messages.jellyfinSettingsDescription, {
|
|
mediaServerName: 'Jellyfin',
|
|
})}
|
|
</p>
|
|
</div>
|
|
<Formik
|
|
initialValues={{
|
|
hostname: data?.ip,
|
|
port: data?.port ?? 8096,
|
|
useSsl: data?.useSsl,
|
|
urlBase: data?.urlBase || '',
|
|
jellyfinExternalUrl: data?.externalHostname || '',
|
|
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
|
|
apiKey: data?.apiKey,
|
|
}}
|
|
validationSchema={JellyfinSettingsSchema}
|
|
onSubmit={async (values) => {
|
|
try {
|
|
const res = await fetch('/api/v1/settings/jellyfin', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
ip: values.hostname,
|
|
port: Number(values.port),
|
|
useSsl: values.useSsl,
|
|
urlBase: values.urlBase,
|
|
externalHostname: values.jellyfinExternalUrl,
|
|
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
|
|
apiKey: values.apiKey,
|
|
} as JellyfinSettings),
|
|
});
|
|
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
|
|
|
addToast(
|
|
intl.formatMessage(messages.jellyfinSettingsSuccess, {
|
|
mediaServerName:
|
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
|
? 'Emby'
|
|
: 'Jellyfin',
|
|
}),
|
|
{
|
|
autoDismiss: true,
|
|
appearance: 'success',
|
|
}
|
|
);
|
|
} catch (e) {
|
|
let errorData;
|
|
try {
|
|
errorData = await e.cause?.text();
|
|
errorData = JSON.parse(errorData);
|
|
} catch {
|
|
/* empty */
|
|
}
|
|
if (errorData?.message === ApiErrorCode.InvalidUrl) {
|
|
addToast(
|
|
intl.formatMessage(messages.invalidurlerror, {
|
|
mediaServerName:
|
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
|
? 'Emby'
|
|
: 'Jellyfin',
|
|
}),
|
|
{
|
|
autoDismiss: true,
|
|
appearance: 'error',
|
|
}
|
|
);
|
|
} else {
|
|
addToast(
|
|
intl.formatMessage(messages.jellyfinSettingsFailure, {
|
|
mediaServerName:
|
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
|
? 'Emby'
|
|
: 'Jellyfin',
|
|
}),
|
|
{
|
|
autoDismiss: true,
|
|
appearance: 'error',
|
|
}
|
|
);
|
|
}
|
|
} finally {
|
|
revalidate();
|
|
}
|
|
}}
|
|
>
|
|
{({
|
|
errors,
|
|
touched,
|
|
values,
|
|
setFieldValue,
|
|
handleSubmit,
|
|
isSubmitting,
|
|
isValid,
|
|
}) => {
|
|
return (
|
|
<form className="section" onSubmit={handleSubmit}>
|
|
{showAdvancedSettings && (
|
|
<>
|
|
<div className="form-row">
|
|
<label htmlFor="hostname" className="text-label">
|
|
{intl.formatMessage(messages.hostname)}
|
|
<span className="text-red-500">*</span>
|
|
</label>
|
|
<div className="form-input-area">
|
|
<div className="form-input-field">
|
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
|
{values.useSsl ? 'https://' : 'http://'}
|
|
</span>
|
|
<Field
|
|
type="text"
|
|
inputMode="url"
|
|
id="hostname"
|
|
name="hostname"
|
|
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
|
|
type="text"
|
|
inputMode="numeric"
|
|
id="port"
|
|
name="port"
|
|
className="short"
|
|
/>
|
|
{errors.port &&
|
|
touched.port &&
|
|
typeof errors.port === 'string' && (
|
|
<div className="error">{errors.port}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="form-row">
|
|
<label htmlFor="useSsl" className="checkbox-label">
|
|
{intl.formatMessage(messages.enablessl)}
|
|
</label>
|
|
<div className="form-input-area">
|
|
<Field
|
|
type="checkbox"
|
|
id="useSsl"
|
|
name="useSsl"
|
|
onChange={() => {
|
|
setFieldValue('useSsl', !values.useSsl);
|
|
setFieldValue('port', values.useSsl ? 8096 : 443);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className="form-row">
|
|
<label htmlFor="apiKey" className="text-label">
|
|
{intl.formatMessage(messages.apiKey)}
|
|
</label>
|
|
<div className="form-input-area">
|
|
<div className="form-input-field">
|
|
<SensitiveInput
|
|
as="field"
|
|
type="text"
|
|
inputMode="url"
|
|
id="apiKey"
|
|
name="apiKey"
|
|
/>
|
|
</div>
|
|
{errors.apiKey && touched.apiKey && (
|
|
<div className="error">{errors.apiKey}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{showAdvancedSettings && (
|
|
<>
|
|
<div className="form-row">
|
|
<label htmlFor="urlBase" className="text-label">
|
|
{intl.formatMessage(messages.urlBase)}
|
|
</label>
|
|
<div className="form-input-area">
|
|
<div className="form-input-field">
|
|
<Field
|
|
type="text"
|
|
inputMode="url"
|
|
id="urlBase"
|
|
name="urlBase"
|
|
/>
|
|
</div>
|
|
{errors.urlBase &&
|
|
touched.urlBase &&
|
|
typeof errors.urlBase === 'string' && (
|
|
<div className="error">{errors.urlBase}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className="form-row">
|
|
<label htmlFor="jellyfinExternalUrl" className="text-label">
|
|
{intl.formatMessage(messages.externalUrl)}
|
|
</label>
|
|
<div className="form-input-area">
|
|
<div className="form-input-field">
|
|
<Field
|
|
type="text"
|
|
inputMode="url"
|
|
id="jellyfinExternalUrl"
|
|
name="jellyfinExternalUrl"
|
|
/>
|
|
</div>
|
|
{errors.jellyfinExternalUrl &&
|
|
touched.jellyfinExternalUrl && (
|
|
<div className="error">{errors.jellyfinExternalUrl}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="form-row">
|
|
<label
|
|
htmlFor="jellyfinForgotPasswordUrl"
|
|
className="text-label"
|
|
>
|
|
{intl.formatMessage(messages.jellyfinForgotPasswordUrl)}
|
|
</label>
|
|
<div className="form-input-area">
|
|
<div className="form-input-field">
|
|
<Field
|
|
type="text"
|
|
inputMode="url"
|
|
id="jellyfinForgotPasswordUrl"
|
|
name="jellyfinForgotPasswordUrl"
|
|
/>
|
|
</div>
|
|
{errors.jellyfinForgotPasswordUrl &&
|
|
touched.jellyfinForgotPasswordUrl && (
|
|
<div className="error">
|
|
{errors.jellyfinForgotPasswordUrl}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="actions">
|
|
<div className="flex justify-end">
|
|
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
|
<Button
|
|
buttonType="primary"
|
|
type="submit"
|
|
disabled={isSubmitting || !isValid}
|
|
>
|
|
<ArrowDownOnSquareIcon />
|
|
<span>
|
|
{isSubmitting
|
|
? intl.formatMessage(globalMessages.saving)
|
|
: intl.formatMessage(globalMessages.save)}
|
|
</span>
|
|
</Button>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
);
|
|
}}
|
|
</Formik>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default SettingsJellyfin;
|