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 = ({ onComplete, showAdvancedSettings, }) => { 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 { 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 ; } return ( <>

{publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? intl.formatMessage(messages.jellyfinlibraries, { mediaServerName: 'Emby', }) : intl.formatMessage(messages.jellyfinlibraries, { mediaServerName: 'Jellyfin', })}

{publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? intl.formatMessage(messages.jellyfinlibrariesDescription, { mediaServerName: 'Emby', }) : intl.formatMessage(messages.jellyfinlibrariesDescription, { mediaServerName: 'Jellyfin', })}

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

{publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? intl.formatMessage(messages.manualscanDescriptionJellyfin, { mediaServerName: 'Emby', }) : intl.formatMessage(messages.manualscanDescriptionJellyfin, { mediaServerName: 'Jellyfin', })}

{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 && ( )}
{showAdvancedSettings && ( <>

{publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? intl.formatMessage(messages.jellyfinSettings, { mediaServerName: 'Emby', }) : intl.formatMessage(messages.jellyfinSettings, { mediaServerName: 'Jellyfin', })}

{publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? intl.formatMessage(messages.jellyfinSettingsDescription, { mediaServerName: 'Emby', }) : intl.formatMessage(messages.jellyfinSettingsDescription, { mediaServerName: 'Jellyfin', })}

{ 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 (
{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.urlBase && touched.urlBase && typeof errors.urlBase === 'string' && (
{errors.urlBase}
)}
{errors.jellyfinExternalUrl && touched.jellyfinExternalUrl && (
{errors.jellyfinExternalUrl}
)}
{errors.jellyfinForgotPasswordUrl && touched.jellyfinForgotPasswordUrl && (
{errors.jellyfinForgotPasswordUrl}
)}
); }}
)} ); }; export default SettingsJellyfin;