import Alert from '@app/components/Common/Alert'; import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; import SensitiveInput from '@app/components/Common/SensitiveInput'; import LibraryItem from '@app/components/Settings/LibraryItem'; import SettingsBadge from '@app/components/Settings/SettingsBadge'; import globalMessages from '@app/i18n/globalMessages'; import { SaveIcon } from '@heroicons/react/outline'; import { RefreshIcon, SearchIcon, XIcon } from '@heroicons/react/solid'; import type { PlexDevice } from '@server/interfaces/api/plexInterfaces'; import type { PlexSettings, TautulliSettings } from '@server/lib/settings'; import axios from 'axios'; import { Field, Formik } from 'formik'; import { orderBy } from 'lodash'; import { useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import * as Yup from 'yup'; const messages = defineMessages({ plex: 'Plex', plexsettings: 'Plex Settings', plexsettingsDescription: 'Configure the settings for your Plex server. Overseerr scans your Plex libraries to determine content availability.', serverpreset: 'Server', serverLocal: 'local', serverRemote: 'remote', serverSecure: 'secure', serverpresetManualMessage: 'Manual configuration', serverpresetRefreshing: 'Retrieving servers…', serverpresetLoad: 'Press the button to load available servers', toastPlexRefresh: 'Retrieving server list from Plex…', toastPlexRefreshSuccess: 'Plex server list retrieved successfully!', toastPlexRefreshFailure: 'Failed to retrieve Plex server list.', toastPlexConnecting: 'Attempting to connect to Plex…', toastPlexConnectingSuccess: 'Plex connection established successfully!', toastPlexConnectingFailure: 'Failed to connect to Plex.', settingUpPlexDescription: 'To set up Plex, you can either enter the details manually or select a server retrieved from plex.tv. Press the button to the right of the dropdown to fetch the list of available servers.', hostname: 'Hostname or IP Address', port: 'Port', enablessl: 'Use SSL', plexlibraries: 'Plex Libraries', plexlibrariesDescription: 'The libraries Overseerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.', scanning: 'Syncing…', scan: 'Sync Libraries', manualscan: 'Manual Library Scan', manualscanDescription: "Normally, this will only be run once every 24 hours. Overseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, 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', validationHostnameRequired: 'You must provide a valid hostname or IP address', validationPortRequired: 'You must provide a valid port number', webAppUrl: 'Web App URL', webAppUrlTip: 'Optionally direct users to the web app on your server instead of the "hosted" web app', tautulliSettings: 'Tautulli Settings', tautulliSettingsDescription: 'Optionally configure the settings for your Tautulli server. Overseerr fetches watch history data for your Plex media from Tautulli.', urlBase: 'URL Base', tautulliApiKey: 'API Key', externalUrl: 'External URL', validationApiKey: 'You must provide an API key', validationUrl: 'You must provide a valid URL', 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', toastTautulliSettingsSuccess: 'Tautulli settings saved successfully!', toastTautulliSettingsFailure: 'Something went wrong while saving Tautulli settings.', }); interface Library { id: string; name: string; enabled: boolean; } interface SyncStatus { running: boolean; progress: number; total: number; currentLibrary?: Library; libraries: Library[]; } interface PresetServerDisplay { name: string; ssl: boolean; uri: string; address: string; port: number; local: boolean; status?: boolean; message?: string; } interface SettingsPlexProps { onComplete?: () => void; } const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { const [isSyncing, setIsSyncing] = useState(false); const [isRefreshingPresets, setIsRefreshingPresets] = useState(false); const [availableServers, setAvailableServers] = useState( null ); const { data, error, mutate: revalidate, } = useSWR('/api/v1/settings/plex'); const { data: dataTautulli, mutate: revalidateTautulli } = useSWR('/api/v1/settings/tautulli'); const { data: dataSync, mutate: revalidateSync } = useSWR( '/api/v1/settings/plex/sync', { refreshInterval: 1000, } ); const intl = useIntl(); const { addToast, removeToast } = useToasts(); const PlexSettingsSchema = 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() .nullable() .required(intl.formatMessage(messages.validationPortRequired)), webAppUrl: Yup.string() .nullable() .url(intl.formatMessage(messages.validationUrl)), }); const TautulliSettingsSchema = Yup.object().shape( { tautulliHostname: Yup.string() .when(['tautulliPort', 'tautulliApiKey'], { is: (value: unknown) => !!value, then: Yup.string() .nullable() .required(intl.formatMessage(messages.validationHostnameRequired)), otherwise: Yup.string().nullable(), }) .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])$/i, intl.formatMessage(messages.validationHostnameRequired) ), tautulliPort: Yup.number().when(['tautulliHostname', 'tautulliApiKey'], { 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(), }), tautulliUrlBase: Yup.string() .test( 'leading-slash', intl.formatMessage(messages.validationUrlBaseLeadingSlash), (value) => !value || value.startsWith('/') ) .test( 'no-trailing-slash', intl.formatMessage(messages.validationUrlBaseTrailingSlash), (value) => !value || !value.endsWith('/') ), tautulliApiKey: Yup.string().when(['tautulliHostname', 'tautulliPort'], { is: (value: unknown) => !!value, then: Yup.string() .nullable() .required(intl.formatMessage(messages.validationApiKey)), otherwise: Yup.string().nullable(), }), tautulliExternalUrl: Yup.string() .url(intl.formatMessage(messages.validationUrl)) .test( 'no-trailing-slash', intl.formatMessage(messages.validationUrlTrailingSlash), (value) => !value || !value.endsWith('/') ), }, [ ['tautulliHostname', 'tautulliPort'], ['tautulliHostname', 'tautulliApiKey'], ['tautulliPort', 'tautulliApiKey'], ] ); const activeLibraries = data?.libraries .filter((library) => library.enabled) .map((library) => library.id) ?? []; const availablePresets = useMemo(() => { const finalPresets: PresetServerDisplay[] = []; availableServers?.forEach((dev) => { dev.connection.forEach((conn) => finalPresets.push({ name: dev.name, ssl: conn.protocol === 'https', uri: conn.uri, address: conn.address, port: conn.port, local: conn.local, status: conn.status === 200, message: conn.message, }) ); }); return orderBy(finalPresets, ['status', 'ssl'], ['desc', 'desc']); }, [availableServers]); const syncLibraries = async () => { setIsSyncing(true); const params: { sync: boolean; enable?: string } = { sync: true, }; if (activeLibraries.length > 0) { params.enable = activeLibraries.join(','); } await axios.get('/api/v1/settings/plex/library', { params, }); setIsSyncing(false); revalidate(); }; const refreshPresetServers = async () => { setIsRefreshingPresets(true); let toastId: string | undefined; try { addToast( intl.formatMessage(messages.toastPlexRefresh), { autoDismiss: false, appearance: 'info', }, (id) => { toastId = id; } ); const response = await axios.get( '/api/v1/settings/plex/devices/servers' ); if (response.data) { setAvailableServers(response.data); } if (toastId) { removeToast(toastId); } addToast(intl.formatMessage(messages.toastPlexRefreshSuccess), { autoDismiss: true, appearance: 'success', }); } catch (e) { if (toastId) { removeToast(toastId); } addToast(intl.formatMessage(messages.toastPlexRefreshFailure), { autoDismiss: true, appearance: 'error', }); } finally { setIsRefreshingPresets(false); } }; const startScan = async () => { await axios.post('/api/v1/settings/plex/sync', { start: true, }); revalidateSync(); }; const cancelScan = async () => { await axios.post('/api/v1/settings/plex/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/plex/library', { params, }); } else { await axios.get('/api/v1/settings/plex/library', { params: { enable: [...activeLibraries, libraryId].join(','), }, }); } setIsSyncing(false); revalidate(); }; if ((!data || !dataTautulli) && !error) { return ; } return ( <>

{intl.formatMessage(messages.plexsettings)}

{intl.formatMessage(messages.plexsettingsDescription)}

{!!onComplete && (
( {msg} ), })} type="info" />
)}
{ let toastId: string | null = null; try { addToast( intl.formatMessage(messages.toastPlexConnecting), { autoDismiss: false, appearance: 'info', }, (id) => { toastId = id; } ); await axios.post('/api/v1/settings/plex', { ip: values.hostname, port: Number(values.port), useSsl: values.useSsl, webAppUrl: values.webAppUrl, } as PlexSettings); syncLibraries(); if (toastId) { removeToast(toastId); } addToast(intl.formatMessage(messages.toastPlexConnectingSuccess), { autoDismiss: true, appearance: 'success', }); if (onComplete) { onComplete(); } } catch (e) { if (toastId) { removeToast(toastId); } addToast(intl.formatMessage(messages.toastPlexConnectingFailure), { autoDismiss: true, appearance: 'error', }); } }} > {({ errors, touched, values, handleSubmit, setFieldValue, 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); }} />
{errors.webAppUrl && touched.webAppUrl && typeof errors.webAppUrl === 'string' && (
{errors.webAppUrl}
)}
); }}

{intl.formatMessage(messages.plexlibraries)}

{intl.formatMessage(messages.plexlibrariesDescription)}

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

{intl.formatMessage(messages.manualscan)}

{intl.formatMessage(messages.manualscanDescription)}

{dataSync?.running && (
)}
{dataSync?.running ? `${dataSync.progress} of ${dataSync.total}` : 'Not running'}
{dataSync?.running && ( <> {dataSync.currentLibrary && (
{intl.formatMessage(messages.currentlibrary, { name: dataSync.currentLibrary.name, })}
)}
{intl.formatMessage(messages.librariesRemaining, { count: dataSync.currentLibrary ? dataSync.libraries.slice( dataSync.libraries.findIndex( (library) => library.id === dataSync.currentLibrary?.id ) + 1 ).length : 0, })}
)}
{!dataSync?.running ? ( ) : ( )}
{!onComplete && ( <>

{intl.formatMessage(messages.tautulliSettings)}

{intl.formatMessage(messages.tautulliSettingsDescription)}

{ try { await axios.post('/api/v1/settings/tautulli', { hostname: values.tautulliHostname, port: Number(values.tautulliPort), useSsl: values.tautulliUseSsl, urlBase: values.tautulliUrlBase, apiKey: values.tautulliApiKey, externalUrl: values.tautulliExternalUrl, } as TautulliSettings); addToast( intl.formatMessage(messages.toastTautulliSettingsSuccess), { autoDismiss: true, appearance: 'success', } ); } catch (e) { addToast( intl.formatMessage(messages.toastTautulliSettingsFailure), { autoDismiss: true, appearance: 'error', } ); } finally { revalidateTautulli(); } }} > {({ errors, touched, values, handleSubmit, setFieldValue, isSubmitting, isValid, }) => { return (
{values.tautulliUseSsl ? 'https://' : 'http://'}
{errors.tautulliHostname && touched.tautulliHostname && typeof errors.tautulliHostname === 'string' && (
{errors.tautulliHostname}
)}
{errors.tautulliPort && touched.tautulliPort && typeof errors.tautulliPort === 'string' && (
{errors.tautulliPort}
)}
{ setFieldValue( 'tautulliUseSsl', !values.tautulliUseSsl ); }} />
{errors.tautulliUrlBase && touched.tautulliUrlBase && typeof errors.tautulliUrlBase === 'string' && (
{errors.tautulliUrlBase}
)}
{errors.tautulliApiKey && touched.tautulliApiKey && typeof errors.tautulliApiKey === 'string' && (
{errors.tautulliApiKey}
)}
{errors.tautulliExternalUrl && touched.tautulliExternalUrl && (
{errors.tautulliExternalUrl}
)}
); }}
)} ); }; export default SettingsPlex;