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 ( {intl.formatMessage(messages.serverpreset)} { const targPreset = availablePresets[Number(e.target.value)]; if (targPreset) { setFieldValue('hostname', targPreset.address); setFieldValue('port', targPreset.port); setFieldValue('useSsl', targPreset.ssl); } }} > {availableServers || isRefreshingPresets ? isRefreshingPresets ? intl.formatMessage( messages.serverpresetRefreshing ) : intl.formatMessage( messages.serverpresetManualMessage ) : intl.formatMessage(messages.serverpresetLoad)} {availablePresets.map((server, index) => ( {` ${server.name} (${server.address}) [${ server.local ? intl.formatMessage(messages.serverLocal) : intl.formatMessage(messages.serverRemote) }]${ server.ssl ? ` [${intl.formatMessage( messages.serverSecure )}]` : '' } ${server.status ? '' : '(' + server.message + ')'} `} ))} { e.preventDefault(); refreshPresetServers(); }} className="input-action" > {intl.formatMessage(messages.hostname)} * {values.useSsl ? 'https://' : 'http://'} {errors.hostname && touched.hostname && typeof errors.hostname === 'string' && ( {errors.hostname} )} {intl.formatMessage(messages.port)} * {errors.port && touched.port && typeof errors.port === 'string' && ( {errors.port} )} {intl.formatMessage(messages.enablessl)} { setFieldValue('useSsl', !values.useSsl); }} /> {intl.formatMessage(messages.webAppUrl, { WebAppLink: (msg: React.ReactNode) => ( {msg} ), })} {intl.formatMessage(messages.webAppUrlTip)} {errors.webAppUrl && touched.webAppUrl && typeof errors.webAppUrl === 'string' && ( {errors.webAppUrl} )} {isSubmitting ? intl.formatMessage(globalMessages.saving) : intl.formatMessage(globalMessages.save)} ); }} {intl.formatMessage(messages.plexlibraries)} {intl.formatMessage(messages.plexlibrariesDescription)} syncLibraries()} disabled={isSyncing || !data?.ip || !data?.port} > {isSyncing ? intl.formatMessage(messages.scanning) : intl.formatMessage(messages.scan)} {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 ? ( startScan()} disabled={isSyncing || !activeLibraries.length} > {intl.formatMessage(messages.startscan)} ) : ( cancelScan()}> {intl.formatMessage(messages.cancelscan)} )} {!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 ( {intl.formatMessage(messages.hostname)} * {values.tautulliUseSsl ? 'https://' : 'http://'} {errors.tautulliHostname && touched.tautulliHostname && typeof errors.tautulliHostname === 'string' && ( {errors.tautulliHostname} )} {intl.formatMessage(messages.port)} * {errors.tautulliPort && touched.tautulliPort && typeof errors.tautulliPort === 'string' && ( {errors.tautulliPort} )} {intl.formatMessage(messages.enablessl)} { setFieldValue( 'tautulliUseSsl', !values.tautulliUseSsl ); }} /> {intl.formatMessage(messages.urlBase)} {errors.tautulliUrlBase && touched.tautulliUrlBase && typeof errors.tautulliUrlBase === 'string' && ( {errors.tautulliUrlBase} )} {intl.formatMessage(messages.tautulliApiKey)} * {errors.tautulliApiKey && touched.tautulliApiKey && typeof errors.tautulliApiKey === 'string' && ( {errors.tautulliApiKey} )} {intl.formatMessage(messages.externalUrl)} {errors.tautulliExternalUrl && touched.tautulliExternalUrl && ( {errors.tautulliExternalUrl} )} {isSubmitting ? intl.formatMessage(globalMessages.saving) : intl.formatMessage(globalMessages.save)} ); }} > )} > ); }; export default SettingsPlex;
{intl.formatMessage(messages.plexsettingsDescription)}
{intl.formatMessage(messages.plexlibrariesDescription)}
{intl.formatMessage(messages.manualscanDescription)}
{intl.formatMessage(messages.tautulliSettingsDescription)}