Files
tunarr/web/src/components/settings/media_source/JelllyfinServerEditDialog.tsx
2025-10-02 07:29:25 -04:00

509 lines
16 KiB
TypeScript

import { isNonEmptyString, isValidUrlWithError, toggle } from '@/helpers/util';
import { RotatingLoopIcon } from '@/components/base/LoadingIcon.tsx';
import { jellyfinLogin } from '@/hooks/jellyfin/useJellyfinLogin.ts';
import { useMediaSourceBackendStatus } from '@/hooks/media-sources/useMediaSourceBackendStatus';
import {
CloudDoneOutlined,
CloudOff,
Visibility,
VisibilityOff,
} from '@mui/icons-material';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
FormControl,
FormHelperText,
IconButton,
InputAdornment,
InputLabel,
OutlinedInput,
Stack,
TextField,
Typography,
} from '@mui/material';
import { type JellyfinServerSettings } from '@tunarr/types';
import { isEmpty, isUndefined } from 'lodash-es';
import { useSnackbar } from 'notistack';
import { type FormEvent, useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { type MarkOptional } from 'ts-essentials';
import { useDebounceCallback, useDebounceValue } from 'usehooks-ts';
import {
useCreateMediaSource,
useUpdateMediaSource,
} from '../../../hooks/media-sources/mediaSourceHooks.ts';
type Props = {
open: boolean;
onClose: () => void;
server?: JellyfinServerSettings;
};
export type JellyfinServerSettingsForm = MarkOptional<
JellyfinServerSettings,
'id'
> & {
password?: string;
};
const emptyDefaults: JellyfinServerSettingsForm = {
type: 'jellyfin',
uri: '',
name: '',
accessToken: '',
username: '',
password: '',
userId: '',
};
export function JellyfinServerEditDialog({ open, onClose, server }: Props) {
const snackbar = useSnackbar();
const [showAccessToken, setShowAccessToken] = useState(false);
const title = server ? `Editing "${server.name}"` : 'New Media Source';
const handleClose = () => {
setShowAccessToken(false);
onClose();
};
const {
control,
watch,
reset,
formState: { isDirty, isValid, defaultValues, errors },
handleSubmit,
setError,
clearErrors,
getValues,
} = useForm<JellyfinServerSettingsForm>({
mode: 'onChange',
defaultValues: server ?? emptyDefaults,
});
useEffect(() => {
if (open) {
reset(server ?? emptyDefaults);
}
}, [reset, open, server]);
// These are updated in a watch callback, so we debounce them
// along with the details we use to check server status. Otherwise
// setting the error will cause us to check server status on every
// keystroke due to re-renders
const debounceSetError = useDebounceCallback(setError);
const debounceClearError = useDebounceCallback(clearErrors);
const createMediaSourceMut = useCreateMediaSource();
const updateMediaSourceMut = useUpdateMediaSource();
const showErrorSnack = (e: unknown) => {
snackbar.enqueueSnackbar({
variant: 'error',
message:
'Error saving new Jellyfin server. See browser console and server logs for details',
});
console.error(e);
};
const onSubmit = async (e: FormEvent<HTMLButtonElement>) => {
e.stopPropagation();
const { accessToken, username, password, uri } = getValues();
if (isNonEmptyString(accessToken)) {
await handleSubmit(
(data) =>
isNonEmptyString(data.id)
? updateMediaSourceMut.mutate({
path: {
id: data.id,
},
body: {
...data,
id: data.id,
userId: null,
username: null,
},
})
: createMediaSourceMut.mutate({
body: {
...data,
userId: null,
username: null,
},
}),
showErrorSnack,
)(e).catch((err) => {
console.error(err);
showErrorSnack(err);
});
handleClose();
} else if (isNonEmptyString(username) && isNonEmptyString(password)) {
try {
const result = await jellyfinLogin({
username,
password,
uri,
});
if (
isNonEmptyString(result.accessToken) &&
isNonEmptyString(result.userId)
) {
await handleSubmit(
(data) =>
isNonEmptyString(data.id)
? updateMediaSourceMut.mutate({
path: {
id: data.id,
},
body: {
...data,
id: data.id,
accessToken: result.accessToken!,
userId: result.userId!,
},
})
: createMediaSourceMut.mutate({
body: {
...data,
accessToken: result.accessToken!,
userId: result.userId!,
},
}),
showErrorSnack,
)(e).catch((err) => {
console.error(err);
showErrorSnack(err);
});
} else {
showErrorSnack(
'Did not receive an accessToken or userId from Jellyfin server.',
);
// Pop snackbar
}
handleClose();
} catch (e) {
showErrorSnack(e);
}
}
};
const [showPassword, setShowPassword] = useState(false);
const [serverStatusDetails, updateServerStatusDetails] = useDebounceValue(
{
id: server?.id && !isDirty ? server.id : undefined,
accessToken: defaultValues?.accessToken ?? '',
uri: defaultValues?.uri ?? '',
},
1000,
{
equalityFn: (left, right) => {
return (
left.id === right.id &&
left.uri === right.uri &&
left.accessToken === right.accessToken
);
},
},
);
// This probably isn't the best way to do this...but it was the only
// way to get it working without infinite re-renders. Idea here is:
// Update the debounced value if relevant details change. Do not rely
// on the debounced value itself in this effect, because then we'll
// just update every time. This watch will fire off every time accessToken
// or URI changes, but the status query will only fire every 500ms
useEffect(() => {
const sub = watch((value, { name }) => {
if (
isNonEmptyString(value.accessToken) ||
(isNonEmptyString(value.username) && isNonEmptyString(value.password))
) {
debounceClearError('root.auth');
} else {
debounceSetError('root.auth', {
message: 'Must provide either access token or username/password',
});
}
if (name === 'uri' || name === 'accessToken') {
updateServerStatusDetails({
id: server?.id && !isDirty ? server.id : undefined,
accessToken: value.accessToken ?? '',
uri: value.uri ?? '',
});
}
});
return () => sub.unsubscribe();
}, [
watch,
updateServerStatusDetails,
server?.id,
isDirty,
debounceClearError,
debounceSetError,
errors,
]);
const { data: serverStatus, isLoading: serverStatusLoading } =
useMediaSourceBackendStatus(
{ ...serverStatusDetails, type: 'jellyfin' },
open,
);
// TODO: Block creation if an existing server with the same URL/name
// already exist
return (
<Dialog open={open} fullWidth keepMounted={false} onClose={() => onClose()}>
<DialogTitle>{title}</DialogTitle>
<DialogContent sx={{ p: 2 }}>
<Box component="form">
<Stack sx={{ py: 2 }} spacing={2}>
<Controller
control={control}
name="uri"
rules={{
validate: {
url: (value) => {
// TODO: dedupe this function
const err = isValidUrlWithError(value);
if (isUndefined(err)) {
return undefined;
}
switch (err) {
case 'empty':
return 'Cannot be empty';
case 'not_parseable':
return 'Not a valid URL';
case 'wrong_protocol':
return 'Protocol must be HTTP or HTTPS';
}
},
},
}}
render={({ field, fieldState: { error } }) => (
<TextField
label="URL"
fullWidth
{...field}
error={
!isUndefined(error) ||
(!isUndefined(serverStatus) && !serverStatus.healthy)
}
helperText={
error?.message ? (
<span>{error.message}</span>
) : !isUndefined(serverStatus) &&
!serverStatus.healthy &&
isNonEmptyString(field.value) ? (
<>
<span>Server is unreachable</span>
<br />
</>
) : null
}
InputProps={{
endAdornment: serverStatusLoading ? (
<RotatingLoopIcon />
) : !isUndefined(serverStatus) && serverStatus.healthy ? (
<CloudDoneOutlined color="success" />
) : (
<CloudOff color="error" />
),
}}
/>
)}
/>
<Controller
control={control}
name="name"
rules={{
required: true,
minLength: 1,
pattern: {
value: /[A-z0-9_-]+/,
message:
'Name can only contain alphanumeric characters, dashes, and underscores',
},
}}
render={({ field, fieldState: { error } }) => (
<TextField
label="Name"
fullWidth
{...field}
error={!isUndefined(error)}
helperText={
error && isNonEmptyString(error.message)
? error.message
: 'Enter a name for your Jellyfin Server'
}
/>
)}
/>
<Box sx={{ mb: 1, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<Controller
control={control}
name="username"
rules={{
required: false,
minLength: 1,
}}
render={({ field, fieldState: { error } }) => (
<FormControl
sx={{ flex: 1 }}
variant="outlined"
// disabled={isNonEmptyString(accessToken)}
>
<InputLabel htmlFor="jellyfin-username">
Username{' '}
</InputLabel>
<OutlinedInput
id="jellyfin-username"
type="text"
error={!isUndefined(error)}
label="Access Token"
{...field}
/>
<FormHelperText>
{error && isNonEmptyString(error.message) && (
<span>{error.message}</span>
)}
</FormHelperText>
</FormControl>
)}
/>
<Controller
control={control}
name="password"
rules={{
required: false,
minLength: 1,
}}
render={({ field, fieldState: { error } }) => (
<FormControl
sx={{ flex: 1 }}
variant="outlined"
// disabled={isNonEmptyString(accessToken)}
>
<InputLabel htmlFor="jellyfin-password">
Password{' '}
</InputLabel>
<OutlinedInput
id="jellyfin-password"
type={showPassword ? 'text' : 'password'}
error={!isUndefined(error)}
endAdornment={
<InputAdornment position="end">
<IconButton
aria-label="toggle access token visibility"
onClick={() => setShowPassword(toggle)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
}
label="Access Token"
{...field}
/>
<FormHelperText>
{error && isNonEmptyString(error.message) && (
<>
<span>{error.message}</span>
<br />
</>
)}
</FormHelperText>
</FormControl>
)}
/>
<FormHelperText sx={{ ml: '14px', mt: -1, flexBasis: '100%' }}>
Enter your Jellyfin password to generate a new access token.
<br />
<strong>NOTE:</strong> These are never saved to the Tunarr DB.
Instead they are sent to Jellyfin to exchange for a session
token.
</FormHelperText>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Divider sx={{ flex: 1 }} />
<Typography variant="caption">OR</Typography>
<Divider sx={{ flex: 1 }} />
</Box>
<Controller
control={control}
name="accessToken"
render={({ field, fieldState: { error } }) => (
<FormControl sx={{ m: 1 }} fullWidth variant="outlined">
<InputLabel htmlFor="access-token">Access Token </InputLabel>
<OutlinedInput
id="access-token"
type={showAccessToken ? 'text' : 'password'}
error={!isUndefined(error)}
endAdornment={
<InputAdornment position="end">
<IconButton
aria-label="toggle access token visibility"
onClick={() => setShowAccessToken(toggle)}
edge="end"
>
{showAccessToken ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
}
label="Access Token"
{...field}
/>
<FormHelperText>
<>
{error && isNonEmptyString(error.message) && (
<>
<span>{error.message}</span>
<br />
</>
)}
<span>
Manually add an access token from your Jellyfin server
</span>
</>
</FormHelperText>
</FormControl>
)}
/>
</Stack>
</Box>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => handleClose()} autoFocus>
Cancel
</Button>
<Button
variant="contained"
disabled={
!isDirty ||
!isValid ||
!isEmpty(errors) ||
serverStatus?.healthy === false
}
type="submit"
onClick={onSubmit}
>
{server?.id ? 'Update' : 'Add'}
</Button>
</DialogActions>
</Dialog>
);
}