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 useSettings from '@app/hooks/useSettings'; 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 { MediaServerType } from '@server/constants/server'; import type { JellyfinSettings } from '@server/lib/settings'; import { Field, Formik } from 'formik'; 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', tip: 'Tip', scanbackground: 'Scanning will run in the background. You can continue the setup process in the meantime.', }); interface Library { id: string; name: string; enabled: boolean; } interface SyncStatus { running: boolean; progress: number; total: number; currentLibrary?: Library; libraries: Library[]; } interface SettingsJellyfinProps { isSetupSettings?: boolean; onComplete?: () => void; } const SettingsJellyfin: React.FC = ({ onComplete, isSetupSettings, }) => { const [isSyncing, setIsSyncing] = useState(false); const toasts = useToasts(); const { data, error, mutate: revalidate, } = useSWR('/api/v1/settings/jellyfin'); const { data: dataSync, mutate: revalidateSync } = useSWR( '/api/v1/settings/jellyfin/sync', { refreshInterval: 1000, } ); const intl = useIntl(); const { addToast } = useToasts(); const settings = useSettings(); 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 ; } const mediaServerFormatValues = { mediaServerName: settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN ? 'Jellyfin' : settings.currentSettings.mediaServerType === MediaServerType.EMBY ? 'Emby' : undefined, }; return ( <>

{intl.formatMessage( messages.jellyfinlibraries, mediaServerFormatValues )}

{intl.formatMessage( messages.jellyfinlibrariesDescription, mediaServerFormatValues )}

    {data?.libraries.map((library) => ( toggleLibrary(library.id)} /> ))}

{intl.formatMessage( messages.manualscanDescriptionJellyfin, mediaServerFormatValues )}

{dataSync?.running && (
)}
{dataSync?.running ? `${dataSync.progress} of ${dataSync.total}` : 'Not running'}
{dataSync?.running && ( <> {dataSync.currentLibrary && (
)}
library.id === dataSync.currentLibrary?.id ) + 1 ).length : 0, }} />
)}
{!dataSync?.running && ( )} {dataSync?.running && ( )}
{isSetupSettings && (
{intl.formatMessage(messages.tip)} {intl.formatMessage(messages.scanbackground)}
)}

{intl.formatMessage( messages.jellyfinSettings, mediaServerFormatValues )}

{intl.formatMessage( messages.jellyfinSettingsDescription, mediaServerFormatValues )}

{ 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, mediaServerFormatValues ), { 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, mediaServerFormatValues ), { autoDismiss: true, appearance: 'error', } ); } else { addToast( intl.formatMessage( messages.jellyfinSettingsFailure, mediaServerFormatValues ), { autoDismiss: true, appearance: 'error', } ); } } finally { revalidate(); } }} > {({ errors, touched, values, setFieldValue, handleSubmit, isSubmitting, isValid, }) => { return (
{!isSetupSettings && ( <>
{values.useSsl ? 'https://' : 'http://'}
{errors.hostname && touched.hostname && typeof errors.hostname === 'string' && (
{errors.hostname}
)}
{errors.port && touched.port && typeof errors.port === 'string' && (
{errors.port}
)}
{ setFieldValue('useSsl', !values.useSsl); setFieldValue('port', values.useSsl ? 8096 : 443); }} />
)}
{errors.apiKey && touched.apiKey && (
{errors.apiKey}
)}
{!isSetupSettings && ( <>
{errors.urlBase && touched.urlBase && typeof errors.urlBase === 'string' && (
{errors.urlBase}
)}
)}
{errors.jellyfinExternalUrl && touched.jellyfinExternalUrl && (
{errors.jellyfinExternalUrl}
)}
{errors.jellyfinForgotPasswordUrl && touched.jellyfinForgotPasswordUrl && (
{errors.jellyfinForgotPasswordUrl}
)}
); }}
); }; export default SettingsJellyfin;