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:
TheCatLady
2022-01-20 05:36:59 -05:00
committed by GitHub
parent 86dff12cde
commit 0842c233d0
19 changed files with 1432 additions and 219 deletions

View File

@@ -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>
</>
)}
</>
);
};