mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-01 12:18:35 -05:00
* refactor(jellyfinsettings): abstract jellyfin hostname, updated ui to reflect it, better validation This PR refactors and abstracts jellyfin hostname into, jellyfin ip, jellyfin port, jellyfin useSsl, and jellyfin urlBase. This makes it more consistent with how plex settings are stored as well. In addition, this improves validation as validation can be applied seperately to them instead of as one whole regex doing the work to validate the url. UI was updated to reflect this. BREAKING CHANGE: Jellyfin settings now does not include a hostname. Instead it abstracted it to ip, port, useSsl, and urlBase. However, migration of old settings to new settings should work automatically. * refactor: remove console logs and use getHostname and ApiErrorCodes * fix: store req.body jellyfin settings temporarily and store only if valid This should fix the issue where settings are saved even if the url was invalid. Now the settings will only be saved if the url is valid. Sort of like a test connection. * refactor: clean up commented out code * refactor(i18n): extract translation keys * fix(auth): auth failing with jellyfin login is disabled * fix(settings): jellyfin migrations replacing the rest of the settings * fix(settings): jellyfin hostname should be carried out if hostname exists * fix(settings): merging the wrong settings source * refactor(settings): use migrator for dynamic settings migrations * refactor(settingsmigrator): settings migration handler and the migrations * test(cypress): fix cypress tests failing cypress settings were lacking some of the jobs so when the startJobs() is called when the app starts, it was failing to schedule the jobs where their cron timings were not specified in the cypress settings. Therefore, this commit adds those jobs back. In addition, other setting options were added to keep cypress settings consistent with a normal user. * chore(prettierignore): ignore cypress/config/settings.cypress.json as it does not need prettier * chore(prettier): ran formatter on cypress config to fix format check error format check locally passes on this file. However, it fails during the github actions format check. Therefore, json language features formatter was run instead of prettier to see if that fixes the issue. * test(cypress): add only missing jobs to the cypress settings * ci: attempt at trying to get formatter to pass on cypress config json file * refactor: revert the changes brought to try and fix formatter added back the rest of the cypress settings and removed cypress settings from .prettierignore * refactor(settings): better erorr logging when jellyfin connection test fails in settings page
670 lines
25 KiB
TypeScript
670 lines
25 KiB
TypeScript
import Badge from '@app/components/Common/Badge';
|
|
import Button from '@app/components/Common/Button';
|
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
|
import LibraryItem from '@app/components/Settings/LibraryItem';
|
|
import globalMessages from '@app/i18n/globalMessages';
|
|
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
|
import { ApiErrorCode } from '@server/constants/error';
|
|
import type { JellyfinSettings } from '@server/lib/settings';
|
|
import axios from 'axios';
|
|
import { Field, Formik } from 'formik';
|
|
import getConfig from 'next/config';
|
|
import type React from 'react';
|
|
import { useState } from 'react';
|
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
import { useToasts } from 'react-toast-notifications';
|
|
import useSWR from 'swr';
|
|
import * as Yup from 'yup';
|
|
|
|
const messages = defineMessages({
|
|
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.',
|
|
externalUrl: 'External URL',
|
|
hostname: 'Hostname or IP Address',
|
|
port: 'Port',
|
|
enablessl: 'Use SSL',
|
|
urlBase: 'URL Base',
|
|
jellyfinForgotPasswordUrl: 'Forgot Password URL',
|
|
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 {
|
|
await axios.get('/api/v1/settings/jellyfin/library', {
|
|
params,
|
|
});
|
|
setIsSyncing(false);
|
|
revalidate();
|
|
} catch (e) {
|
|
if (e.response.data.message === 'SYNC_ERROR_GROUPED_FOLDERS') {
|
|
toasts.addToast(
|
|
intl.formatMessage(
|
|
messages.jellyfinSyncFailedAutomaticGroupedFolders
|
|
),
|
|
{
|
|
autoDismiss: true,
|
|
appearance: 'warning',
|
|
}
|
|
);
|
|
} else if (e.response.data.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 () => {
|
|
await axios.post('/api/v1/settings/jellyfin/sync', {
|
|
start: true,
|
|
});
|
|
revalidateSync();
|
|
};
|
|
|
|
const cancelScan = async () => {
|
|
await axios.post('/api/v1/settings/jellyfin/sync', {
|
|
cancel: true,
|
|
});
|
|
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(',');
|
|
}
|
|
|
|
await axios.get('/api/v1/settings/jellyfin/library', {
|
|
params,
|
|
});
|
|
} else {
|
|
await axios.get('/api/v1/settings/jellyfin/library', {
|
|
params: {
|
|
enable: [...activeLibraries, libraryId].join(','),
|
|
},
|
|
});
|
|
}
|
|
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>
|
|
{showAdvancedSettings && (
|
|
<>
|
|
<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 || '',
|
|
}}
|
|
validationSchema={JellyfinSettingsSchema}
|
|
onSubmit={async (values) => {
|
|
try {
|
|
await axios.post('/api/v1/settings/jellyfin', {
|
|
ip: values.hostname,
|
|
port: Number(values.port),
|
|
useSsl: values.useSsl,
|
|
urlBase: values.urlBase,
|
|
externalHostname: values.jellyfinExternalUrl,
|
|
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
|
|
} as JellyfinSettings);
|
|
|
|
addToast(
|
|
intl.formatMessage(messages.jellyfinSettingsSuccess, {
|
|
mediaServerName:
|
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
|
? 'Emby'
|
|
: 'Jellyfin',
|
|
}),
|
|
{
|
|
autoDismiss: true,
|
|
appearance: 'success',
|
|
}
|
|
);
|
|
} catch (e) {
|
|
if (e.response?.data?.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}>
|
|
<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="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;
|