From 82ac76b0540ba1133cb5384744d2499c2488a4e8 Mon Sep 17 00:00:00 2001 From: Chris Pritchard Date: Mon, 25 Jan 2021 07:34:21 +0000 Subject: [PATCH] feat: pre-populate server info from plex.tv API (#563) Co-authored-by: sct --- .eslintrc.js | 1 + .gitignore | 3 + overseerr-api.yml | 149 +++++++++ server/api/plexapi.ts | 24 +- server/api/plextv.ts | 96 ++++++ server/interfaces/api/plexInterfaces.ts | 45 +++ server/routes/settings/index.ts | 65 +++- server/types/plex-api.d.ts | 3 +- src/components/Common/Alert/index.tsx | 25 +- src/components/Settings/SettingsPlex.tsx | 384 +++++++++++++++++++---- src/i18n/locale/en.json | 22 +- 11 files changed, 741 insertions(+), 76 deletions(-) create mode 100644 server/interfaces/api/plexInterfaces.ts diff --git a/.eslintrc.js b/.eslintrc.js index a5518f73a..c7286440c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -32,6 +32,7 @@ module.exports = { 'formatjs/no-offset': 'error', 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': ['error'], + 'jsx-a11y/no-onchange': 'off', }, overrides: [ { diff --git a/.gitignore b/.gitignore index 968e54929..b13c94726 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ dist # sqlite journal config/db/db.sqlite3-journal + +# VS Code +.vscode/launch.json diff --git a/overseerr-api.yml b/overseerr-api.yml index d3c526067..21ad93df2 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -113,6 +113,140 @@ components: - machineId - ip - port + PlexStatus: + type: object + properties: + settings: + $ref: '#/components/schemas/PlexSettings' + status: + type: number + example: 200 + message: + type: string + example: 'OK' + PlexConnection: + type: object + properties: + protocol: + type: string + example: 'https' + address: + type: string + example: '127.0.0.1' + port: + type: number + example: 32400 + uri: + type: string + example: 'https://127-0-0-1.2ab6ce1a093d465e910def96cf4e4799.plex.direct:32400' + local: + type: boolean + example: true + status: + type: number + example: 200 + message: + type: string + example: 'OK' + host: + type: string + example: '127-0-0-1.2ab6ce1a093d465e910def96cf4e4799.plex.direct' + required: + - protocol + - address + - port + - uri + - local + PlexDevice: + type: object + properties: + name: + type: string + example: 'My Plex Server' + product: + type: string + example: 'Plex Media Server' + productVersion: + type: string + example: '1.21' + platform: + type: string + example: 'Linux' + platformVersion: + type: string + example: 'default/linux/amd64/17.1/systemd' + device: + type: string + example: 'PC' + clientIdentifier: + type: string + example: '85a943ce-a0cc-4d2a-a4ec-f74f06e40feb' + createdAt: + type: string + example: '2021-01-01T00:00:00.000Z' + lastSeenAt: + type: string + example: '2021-01-01T00:00:00.000Z' + provides: + type: array + items: + type: string + example: 'server' + owned: + type: boolean + example: true + ownerID: + type: string + example: '12345' + home: + type: boolean + example: true + sourceTitle: + type: string + example: 'xyzabc' + accessToken: + type: string + example: 'supersecretaccesstoken' + publicAddress: + type: string + example: '127.0.0.1' + httpsRequired: + type: boolean + example: true + synced: + type: boolean + example: true + relay: + type: boolean + example: true + dnsRebindingProtection: + type: boolean + example: false + natLoopbackSupported: + type: boolean + example: false + publicAddressMatches: + type: boolean + example: false + presence: + type: boolean + example: true + connection: + type: array + items: + $ref: '#/components/schemas/PlexConnection' + required: + - name + - product + - productVersion + - platform + - device + - clientIdentifier + - createdAt + - lastSeenAt + - provides + - owned + - connection RadarrSettings: type: object properties: @@ -1460,6 +1594,21 @@ paths: type: array items: $ref: '#/components/schemas/PlexLibrary' + /settings/plex/devices/servers: + get: + summary: Gets the user's available plex servers + description: Returns a list of available plex servers and their connectivity state + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PlexDevice' /settings/radarr: get: summary: Get all radarr settings diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index 43487c21e..b87dc3421 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -1,5 +1,5 @@ import NodePlexAPI from 'plex-api'; -import { getSettings } from '../lib/settings'; +import { getSettings, PlexSettings } from '../lib/settings'; export interface PlexLibraryItem { ratingKey: string; @@ -80,13 +80,26 @@ interface PlexMetadataResponse { class PlexAPI { private plexClient: NodePlexAPI; - constructor({ plexToken }: { plexToken?: string }) { + constructor({ + plexToken, + plexSettings, + timeout, + }: { + plexToken?: string; + plexSettings?: PlexSettings; + timeout?: number; + }) { const settings = getSettings(); + let settingsPlex: PlexSettings | undefined; + plexSettings + ? (settingsPlex = plexSettings) + : (settingsPlex = getSettings().plex); this.plexClient = new NodePlexAPI({ - hostname: settings.plex.ip, - port: settings.plex.port, - https: settings.plex.useSsl, + hostname: settingsPlex.ip, + port: settingsPlex.port, + https: settingsPlex.useSsl, + timeout: timeout, token: plexToken, authenticator: { authenticate: ( @@ -111,6 +124,7 @@ class PlexAPI { }); } + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types public async getStatus() { return await this.plexClient.query('/'); } diff --git a/server/api/plextv.ts b/server/api/plextv.ts index e3e40c73e..c925bec61 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -1,5 +1,6 @@ import axios, { AxiosInstance } from 'axios'; import xml2js from 'xml2js'; +import { PlexDevice } from '../interfaces/api/plexInterfaces'; import { getSettings } from '../lib/settings'; import logger from '../logger'; @@ -29,6 +30,45 @@ interface PlexUser { entitlements: string[]; } +interface ConnectionResponse { + $: { + protocol: string; + address: string; + port: string; + uri: string; + local: string; + }; +} + +interface DeviceResponse { + $: { + name: string; + product: string; + productVersion: string; + platform: string; + platformVersion: string; + device: string; + clientIdentifier: string; + createdAt: string; + lastSeenAt: string; + provides: string; + owned: string; + accessToken?: string; + publicAddress?: string; + httpsRequired?: string; + synced?: string; + relay?: string; + dnsRebindingProtection?: string; + natLoopbackSupported?: string; + publicAddressMatches?: string; + presence?: string; + ownerID?: string; + home?: string; + sourceTitle?: string; + }; + Connection: ConnectionResponse[]; +} + interface ServerResponse { $: { id: string; @@ -87,6 +127,62 @@ class PlexTvAPI { }); } + public async getDevices(): Promise { + try { + const devicesResp = await this.axios.get( + '/api/resources?includeHttps=1', + { + transformResponse: [], + responseType: 'text', + } + ); + const parsedXml = await xml2js.parseStringPromise( + devicesResp.data as DeviceResponse + ); + return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({ + name: pxml.$.name, + product: pxml.$.product, + productVersion: pxml.$.productVersion, + platform: pxml.$?.platform, + platformVersion: pxml.$?.platformVersion, + device: pxml.$?.device, + clientIdentifier: pxml.$.clientIdentifier, + createdAt: new Date(parseInt(pxml.$?.createdAt, 10) * 1000), + lastSeenAt: new Date(parseInt(pxml.$?.lastSeenAt, 10) * 1000), + provides: pxml.$.provides.split(','), + owned: pxml.$.owned == '1' ? true : false, + accessToken: pxml.$?.accessToken, + publicAddress: pxml.$?.publicAddress, + publicAddressMatches: + pxml.$?.publicAddressMatches == '1' ? true : false, + httpsRequired: pxml.$?.httpsRequired == '1' ? true : false, + synced: pxml.$?.synced == '1' ? true : false, + relay: pxml.$?.relay == '1' ? true : false, + dnsRebindingProtection: + pxml.$?.dnsRebindingProtection == '1' ? true : false, + natLoopbackSupported: + pxml.$?.natLoopbackSupported == '1' ? true : false, + presence: pxml.$?.presence == '1' ? true : false, + ownerID: pxml.$?.ownerID, + home: pxml.$?.home == '1' ? true : false, + sourceTitle: pxml.$?.sourceTitle, + connection: pxml?.Connection?.map((conn: ConnectionResponse) => ({ + protocol: conn.$.protocol, + address: conn.$.address, + port: parseInt(conn.$.port, 10), + uri: conn.$.uri, + local: conn.$.local == '1' ? true : false, + })), + })); + } catch (e) { + logger.error('Something went wrong getting the devices from plex.tv', { + label: 'Plex.tv API', + errorMessage: e.message, + }); + throw new Error('Invalid auth token'); + } + } + public async getUser(): Promise { try { const account = await this.axios.get( diff --git a/server/interfaces/api/plexInterfaces.ts b/server/interfaces/api/plexInterfaces.ts new file mode 100644 index 000000000..42ec9cb4a --- /dev/null +++ b/server/interfaces/api/plexInterfaces.ts @@ -0,0 +1,45 @@ +import { PlexSettings } from '../../lib/settings'; + +export interface PlexStatus { + settings: PlexSettings; + status: number; + message: string; +} + +export interface PlexConnection { + protocol: string; + address: string; + port: number; + uri: string; + local: boolean; + status?: number; + message?: string; + host?: string; +} + +export interface PlexDevice { + name: string; + product: string; + productVersion: string; + platform: string; + platformVersion: string; + device: string; + clientIdentifier: string; + createdAt: Date; + lastSeenAt: Date; + provides: string[]; + owned: boolean; + accessToken?: string; + publicAddress?: string; + httpsRequired?: boolean; + synced?: boolean; + relay?: boolean; + dnsRebindingProtection?: boolean; + natLoopbackSupported?: boolean; + publicAddressMatches?: boolean; + presence?: boolean; + ownerID?: string; + home?: boolean; + sourceTitle?: string; + connection: PlexConnection[]; +} diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 0b6ebaf4f..5a1230bbd 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -9,6 +9,7 @@ import { import { getRepository } from 'typeorm'; import { User } from '../../entity/User'; import PlexAPI from '../../api/plexapi'; +import PlexTvAPI from '../../api/plextv'; import { jobPlexFullSync } from '../../job/plexsync'; import SonarrAPI from '../../api/sonarr'; import RadarrAPI from '../../api/radarr'; @@ -106,6 +107,69 @@ settingsRoutes.post('/plex', async (req, res, next) => { return res.status(200).json(settings.plex); }); +settingsRoutes.get('/plex/devices/servers', async (req, res, next) => { + const userRepository = getRepository(User); + const regexp = /(http(s?):\/\/)(.*)(:[0-9]*)/; + try { + const admin = await userRepository.findOneOrFail({ + select: ['id', 'plexToken'], + order: { id: 'ASC' }, + }); + const plexTvClient = admin.plexToken + ? new PlexTvAPI(admin.plexToken) + : null; + const devices = (await plexTvClient?.getDevices())?.filter((device) => { + return device.provides.includes('server') && device.owned; + }); + const settings = getSettings(); + if (devices) { + await Promise.all( + devices.map(async (device) => { + await Promise.all( + device.connection.map(async (connection) => { + connection.host = connection.uri.replace(regexp, '$3'); + let msg: + | { status: number; message: string } + | undefined = undefined; + const plexDeviceSettings = { + ...settings.plex, + ip: connection.host, + port: connection.port, + useSsl: connection.protocol === 'https' ? true : false, + }; + const plexClient = new PlexAPI({ + plexToken: admin.plexToken, + plexSettings: plexDeviceSettings, + timeout: 5000, + }); + try { + await plexClient.getStatus(); + msg = { + status: 200, + message: 'OK', + }; + } catch (e) { + msg = { + status: 500, + message: e.message, + }; + } + connection.status = msg?.status; + connection.message = msg?.message; + }) + ); + }) + ); + } + return res.status(200).json(devices); + } catch (e) { + return next({ + status: 500, + message: `Failed to connect to Plex: ${e.message}`, + }); + } +}); + settingsRoutes.get('/plex/library', async (req, res) => { const settings = getSettings(); @@ -156,7 +220,6 @@ settingsRoutes.get('/plex/sync', (req, res) => { } else if (req.query.start) { jobPlexFullSync.run(); } - return res.status(200).json(jobPlexFullSync.status()); }); diff --git a/server/types/plex-api.d.ts b/server/types/plex-api.d.ts index 9222faafc..2e6cdc165 100644 --- a/server/types/plex-api.d.ts +++ b/server/types/plex-api.d.ts @@ -5,6 +5,7 @@ declare module 'plex-api' { port: number; token?: string; https?: boolean; + timeout?: number; authenticator: { authenticate: ( _plexApi: PlexAPI, @@ -19,7 +20,7 @@ declare module 'plex-api' { }; requestOptions?: Record; }); - + // eslint-disable-next-line @typescript-eslint/no-explicit-any query: >(endpoint: string) => Promise; } } diff --git a/src/components/Common/Alert/index.tsx b/src/components/Common/Alert/index.tsx index 0202c27db..15d63eae6 100644 --- a/src/components/Common/Alert/index.tsx +++ b/src/components/Common/Alert/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; interface AlertProps { title: string; - type?: 'warning' | 'info'; + type?: 'warning' | 'info' | 'error'; } const Alert: React.FC = ({ title, children, type }) => { @@ -51,6 +51,29 @@ const Alert: React.FC = ({ title, children, type }) => { ), }; break; + case 'error': + design = { + bgColor: 'bg-red-600', + titleColor: 'text-red-200', + textColor: 'text-red-300', + svg: ( + + + + ), + }; + break; } return ( diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index 34ded09a8..8f4bfb571 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -1,7 +1,9 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import LoadingSpinner from '../Common/LoadingSpinner'; import type { PlexSettings } from '../../../server/lib/settings'; +import type { PlexDevice } from '../../../server/interfaces/api/plexInterfaces'; import useSWR from 'swr'; +import { useToasts } from 'react-toast-notifications'; import { Formik, Field } from 'formik'; import Button from '../Common/Button'; import axios from 'axios'; @@ -9,16 +11,38 @@ import LibraryItem from './LibraryItem'; import Badge from '../Common/Badge'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import * as Yup from 'yup'; +import Alert from '../Common/Alert'; const messages = defineMessages({ plexsettings: 'Plex Settings', plexsettingsDescription: 'Configure the settings for your Plex server. Overseerr uses your Plex server to scan your library at an interval and see what content is available.', - servername: 'Server Name (Automatically set after you save)', + servername: 'Server Name (Retrieved from Plex)', servernamePlaceholder: 'Plex Server Name', + serverpreset: 'Available Server', + serverpresetPlaceholder: 'Plex Server (Retrieved Automatically)', + serverLocal: 'local', + serverRemote: 'remote', + serverConnected: 'connected', + serverpresetManualMessage: 'Manually configure', + serverpresetRefreshing: 'Retrieving servers...', + serverpresetLoad: 'Press button to load available servers', + toastPlexRefresh: 'Retrieving server list from Plex', + toastPlexRefreshSuccess: 'Retrieved server list from Plex', + toastPlexRefreshFailure: 'Unable to retrieve server list from Plex', + toastPlexConnecting: 'Attempting to connect to Plex server', + toastPlexConnectingSuccess: 'Connected to Plex server', + toastPlexConnectingFailure: 'Unable to connect to Plex server', + settingUpPlex: 'Setting up Plex', + settingUpPlexDescription: + 'To setup Plex you can enter your details manually, \ + or choose from one of the available servers retrieved from plex.tv.\ + Press the button next to the dropdown to retrieve available servers and check connectivity.', hostname: 'Hostname/IP', port: 'Port', ssl: 'SSL', + timeout: 'Timeout', + ms: 'ms', save: 'Save Changes', saving: 'Saving...', plexlibraries: 'Plex Libraries', @@ -52,24 +76,41 @@ interface SyncStatus { libraries: Library[]; } +interface PresetServerDisplay { + name: string; + ssl: boolean; + uri: string; + address: string; + host?: string; + port: number; + local: boolean; + status?: boolean; + message?: string; +} interface SettingsPlexProps { onComplete?: () => void; } const SettingsPlex: React.FC = ({ onComplete }) => { - const intl = useIntl(); - const { data, error, revalidate } = useSWR( - '/api/v1/settings/plex' + const [isSyncing, setIsSyncing] = useState(false); + const [isRefreshingPresets, setIsRefreshingPresets] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [availableServers, setAvailableServers] = useState( + null ); + const { + data: data, + error: error, + revalidate: revalidate, + } = useSWR('/api/v1/settings/plex'); const { data: dataSync, revalidate: revalidateSync } = useSWR( '/api/v1/settings/plex/sync', { refreshInterval: 1000, } ); - const [isSyncing, setIsSyncing] = useState(false); - const [submitError, setSubmitError] = useState(null); - + const intl = useIntl(); + const { addToast, removeToast } = useToasts(); const PlexSettingsSchema = Yup.object().shape({ hostname: Yup.string().required( intl.formatMessage(messages.validationHostnameRequired) @@ -84,6 +125,33 @@ const SettingsPlex: React.FC = ({ onComplete }) => { .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' ? true : false, + uri: conn.uri, + address: conn.address, + port: conn.port, + local: conn.local, + host: conn.host, + status: conn.status === 200 ? true : false, + message: conn.message, + }) + ); + }); + finalPresets.sort((a, b) => { + if (a.status && !b.status) { + return -1; + } else { + return 1; + } + }); + return finalPresets; + }, [availableServers]); + const syncLibraries = async () => { setIsSyncing(true); @@ -102,6 +170,46 @@ const SettingsPlex: React.FC = ({ onComplete }) => { 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.get('/api/v1/settings/plex/sync', { params: { @@ -157,18 +265,46 @@ const SettingsPlex: React.FC = ({ onComplete }) => {

+
+ + {intl.formatMessage(messages.settingUpPlexDescription, { + RegisterPlexTVLink: function RegisterPlexTVLink(msg) { + return ( + + {msg} + + ); + }, + })} + +
{ - setSubmitError(null); + 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), @@ -176,10 +312,25 @@ const SettingsPlex: React.FC = ({ onComplete }) => { } as PlexSettings); revalidate(); + setSubmitError(null); + 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', + }); setSubmitError(e.response.data.message); } }} @@ -190,16 +341,12 @@ const SettingsPlex: React.FC = ({ onComplete }) => { values, handleSubmit, setFieldValue, + setFieldTouched, isSubmitting, }) => { return (
- {submitError && ( -
- {submitError} -
- )}
-
- - {values.useSsl ? 'https://' : 'http://'} - - +
+ +
- {errors.hostname && touched.hostname && ( -
{errors.hostname}
- )}
-
- -
-
+
+
+
+ +
+
+ + {values.useSsl ? 'https://' : 'http://'} + + +
+ {errors.hostname && touched.hostname && ( +
+ {errors.hostname} +
+ )} +
+
+
+
+
+ +
+
+ +
+ {errors.port && touched.port && ( +
{errors.port}
+ )} +
+
+
+
+ +
{ + setFieldValue('useSsl', !values.useSsl); + }} + className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" />
- {errors.port && touched.port && ( -
{errors.port}
- )}
-
- -
- { - setFieldValue('useSsl', !values.useSsl); - }} - className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" - /> + {submitError && ( +
+ + {submitError} +
-
+ )}
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index bb9c72fd6..be5768790 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -403,6 +403,7 @@ "components.Settings.menuNotifications": "Notifications", "components.Settings.menuPlexSettings": "Plex", "components.Settings.menuServices": "Services", + "components.Settings.ms": "ms", "components.Settings.nextexecution": "Next Execution", "components.Settings.nodefault": "No default server selected!", "components.Settings.nodefaultdescription": "At least one server must be marked as default before any requests will make it to your services.", @@ -423,16 +424,33 @@ "components.Settings.runnow": "Run Now", "components.Settings.save": "Save Changes", "components.Settings.saving": "Saving…", - "components.Settings.servername": "Server Name (Automatically set after you save)", + "components.Settings.serverConnected": "connected", + "components.Settings.serverLocal": "local", + "components.Settings.serverRemote": "remote", + "components.Settings.servername": "Server Name (Retrieved from Plex)", "components.Settings.servernamePlaceholder": "Plex Server Name", + "components.Settings.serverpreset": "Available Server", + "components.Settings.serverpresetLoad": "Press button to load available servers", + "components.Settings.serverpresetManualMessage": "Manually configure", + "components.Settings.serverpresetPlaceholder": "Plex server (retrieved automatically)", + "components.Settings.serverpresetRefreshing": "Retrieving servers...", + "components.Settings.settingUpPlex": "Setting up Plex", + "components.Settings.settingUpPlexDescription": "To setup Plex you can enter your details manually, or choose from one of your available servers retrieved from plex.tv. Press the button next to the dropdown to refresh the list and recheck server connectivity.", "components.Settings.sonarrSettingsDescription": "Set up your Sonarr connection below. You can have multiple, but only two active as defaults at any time (one for standard HD and one for 4K). Administrators can override which server is used for new requests.", "components.Settings.sonarrsettings": "Sonarr Settings", "components.Settings.ssl": "SSL", "components.Settings.startscan": "Start Scan", "components.Settings.sync": "Sync Plex Libraries", "components.Settings.syncing": "Syncing…", + "components.Settings.timeout": "Timeout", "components.Settings.toastApiKeyFailure": "Something went wrong generating a new API Key.", - "components.Settings.toastApiKeySuccess": "New API key generated!", + "components.Settings.toastApiKeySuccess": "New API Key generated!", + "components.Settings.toastPlexConnecting": "Attempting to connect to Plex server", + "components.Settings.toastPlexConnectingFailure": "Unable to connect to Plex server", + "components.Settings.toastPlexConnectingSuccess": "Connected to Plex server", + "components.Settings.toastPlexRefresh": "Retrieving server list from Plex", + "components.Settings.toastPlexRefreshFailure": "Unable to retrieve server list from Plex", + "components.Settings.toastPlexRefreshSuccess": "Retrieved server list from Plex", "components.Settings.toastSettingsFailure": "Something went wrong saving settings.", "components.Settings.toastSettingsSuccess": "Settings saved.", "components.Settings.validationHostnameRequired": "You must provide a hostname/IP.",