mirror of
https://github.com/sct/overseerr.git
synced 2025-09-28 04:52:59 +02:00
feat: Tautulli integration (#2230)
* feat: media/user watch history data via Tautulli * fix(frontend): only display slideover cog button if there is media to manage * fix(lang): tweak permission denied messages * refactor: reorder Media section in slideover * refactor: use new Tautulli stats API * fix(frontend): do not attempt to fetch data when user lacks req perms * fix: remove unneccessary get_user requests * feat(frontend): display user avatars * feat: add external URL setting * feat: add play counts for past week/month * fix(lang): tweak strings Co-authored-by: Ryan Cohen <ryan@sct.dev>
This commit is contained in:
@@ -9,13 +9,17 @@ import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import * as Yup from 'yup';
|
||||
import type { PlexDevice } from '../../../server/interfaces/api/plexInterfaces';
|
||||
import type { PlexSettings } from '../../../server/lib/settings';
|
||||
import type {
|
||||
PlexSettings,
|
||||
TautulliSettings,
|
||||
} from '../../../server/lib/settings';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Alert from '../Common/Alert';
|
||||
import Badge from '../Common/Badge';
|
||||
import Button from '../Common/Button';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import SensitiveInput from '../Common/SensitiveInput';
|
||||
import LibraryItem from './LibraryItem';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -59,7 +63,20 @@ const messages = defineMessages({
|
||||
webAppUrl: '<WebAppLink>Web App</WebAppLink> URL',
|
||||
webAppUrlTip:
|
||||
'Optionally direct users to the web app on your server instead of the "hosted" web app',
|
||||
validationWebAppUrl: 'You must provide a valid Plex Web App URL',
|
||||
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 {
|
||||
@@ -101,6 +118,8 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
|
||||
error,
|
||||
mutate: revalidate,
|
||||
} = useSWR<PlexSettings>('/api/v1/settings/plex');
|
||||
const { data: dataTautulli, mutate: revalidateTautulli } =
|
||||
useSWR<TautulliSettings>('/api/v1/settings/tautulli');
|
||||
const { data: dataSync, mutate: revalidateSync } = useSWR<SyncStatus>(
|
||||
'/api/v1/settings/plex/sync',
|
||||
{
|
||||
@@ -109,6 +128,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
|
||||
);
|
||||
const intl = useIntl();
|
||||
const { addToast, removeToast } = useToasts();
|
||||
|
||||
const PlexSettingsSchema = Yup.object().shape({
|
||||
hostname: Yup.string()
|
||||
.nullable()
|
||||
@@ -122,9 +142,66 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
|
||||
.required(intl.formatMessage(messages.validationPortRequired)),
|
||||
webAppUrl: Yup.string()
|
||||
.nullable()
|
||||
.url(intl.formatMessage(messages.validationWebAppUrl)),
|
||||
.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)
|
||||
@@ -247,7 +324,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
|
||||
revalidate();
|
||||
};
|
||||
|
||||
if (!data && !error) {
|
||||
if ((!data || !dataTautulli) && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
return (
|
||||
@@ -646,6 +723,209 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!onComplete && (
|
||||
<>
|
||||
<div className="mt-10 mb-6">
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(messages.tautulliSettings)}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.tautulliSettingsDescription)}
|
||||
</p>
|
||||
</div>
|
||||
<Formik
|
||||
initialValues={{
|
||||
tautulliHostname: dataTautulli?.hostname,
|
||||
tautulliPort: dataTautulli?.port ?? 8181,
|
||||
tautulliUseSsl: dataTautulli?.useSsl,
|
||||
tautulliUrlBase: dataTautulli?.urlBase,
|
||||
tautulliApiKey: dataTautulli?.apiKey,
|
||||
tautulliExternalUrl: dataTautulli?.externalUrl,
|
||||
}}
|
||||
validationSchema={TautulliSettingsSchema}
|
||||
onSubmit={async (values) => {
|
||||
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 (
|
||||
<form className="section" onSubmit={handleSubmit}>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tautulliHostname" className="text-label">
|
||||
{intl.formatMessage(messages.hostname)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<span className="inline-flex items-center px-3 text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md sm:text-sm">
|
||||
{values.tautulliUseSsl ? 'https://' : 'http://'}
|
||||
</span>
|
||||
<Field
|
||||
type="text"
|
||||
inputMode="url"
|
||||
id="tautulliHostname"
|
||||
name="tautulliHostname"
|
||||
className="rounded-r-only"
|
||||
/>
|
||||
</div>
|
||||
{errors.tautulliHostname && touched.tautulliHostname && (
|
||||
<div className="error">{errors.tautulliHostname}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tautulliPort" className="text-label">
|
||||
{intl.formatMessage(messages.port)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
id="tautulliPort"
|
||||
name="tautulliPort"
|
||||
className="short"
|
||||
/>
|
||||
{errors.tautulliPort && touched.tautulliPort && (
|
||||
<div className="error">{errors.tautulliPort}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tautulliUseSsl" className="checkbox-label">
|
||||
{intl.formatMessage(messages.enablessl)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="tautulliUseSsl"
|
||||
name="tautulliUseSsl"
|
||||
onChange={() => {
|
||||
setFieldValue(
|
||||
'tautulliUseSsl',
|
||||
!values.tautulliUseSsl
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tautulliUrlBase" className="text-label">
|
||||
{intl.formatMessage(messages.urlBase)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
type="text"
|
||||
inputMode="url"
|
||||
id="tautulliUrlBase"
|
||||
name="tautulliUrlBase"
|
||||
/>
|
||||
</div>
|
||||
{errors.tautulliUrlBase && touched.tautulliUrlBase && (
|
||||
<div className="error">{errors.tautulliUrlBase}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tautulliApiKey" className="text-label">
|
||||
{intl.formatMessage(messages.tautulliApiKey)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<SensitiveInput
|
||||
as="field"
|
||||
id="tautulliApiKey"
|
||||
name="tautulliApiKey"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
{errors.tautulliApiKey && touched.tautulliApiKey && (
|
||||
<div className="error">{errors.tautulliApiKey}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tautulliExternalUrl" className="text-label">
|
||||
{intl.formatMessage(messages.externalUrl)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
type="text"
|
||||
inputMode="url"
|
||||
id="tautulliExternalUrl"
|
||||
name="tautulliExternalUrl"
|
||||
/>
|
||||
</div>
|
||||
{errors.tautulliExternalUrl &&
|
||||
touched.tautulliExternalUrl && (
|
||||
<div className="error">
|
||||
{errors.tautulliExternalUrl}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: intl.formatMessage(globalMessages.save)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
Reference in New Issue
Block a user