feat(jellyfinapi): add API key field to Jellyfin settings

This commit is contained in:
Gauthier
2024-08-08 11:34:15 +02:00
parent 3dafc362ed
commit 3aab8e94e3
5 changed files with 210 additions and 185 deletions

View File

@@ -93,9 +93,7 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
} }
class JellyfinAPI extends ExternalAPI { class JellyfinAPI extends ExternalAPI {
private authToken?: string;
private userId?: string; private userId?: string;
private jellyfinHost: string;
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) { constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
let authHeaderVal: string; let authHeaderVal: string;
@@ -114,9 +112,6 @@ class JellyfinAPI extends ExternalAPI {
}, },
} }
); );
this.jellyfinHost = jellyfinHost;
this.authToken = authToken;
} }
public async login( public async login(

View File

@@ -334,6 +334,14 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
userType: UserType.JELLYFIN, userType: UserType.JELLYFIN,
}); });
// Create an API key on Jellyfin from this admin user
const jellyfinClient = new JellyfinAPI(
hostname,
account.AccessToken,
deviceId
);
const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
const serverName = await jellyfinserver.getServerName(); const serverName = await jellyfinserver.getServerName();
settings.jellyfin.name = serverName; settings.jellyfin.name = serverName;
@@ -342,6 +350,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
settings.jellyfin.port = body.port ?? 8096; settings.jellyfin.port = body.port ?? 8096;
settings.jellyfin.urlBase = body.urlBase ?? ''; settings.jellyfin.urlBase = body.urlBase ?? '';
settings.jellyfin.useSsl = body.useSsl ?? false; settings.jellyfin.useSsl = body.useSsl ?? false;
settings.jellyfin.apiKey = apiKey;
settings.save(); settings.save();
startJobs(); startJobs();

View File

@@ -270,7 +270,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
getHostname(tempJellyfinSettings), getHostname(tempJellyfinSettings),
settings.jellyfin.apiKey, tempJellyfinSettings.apiKey,
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );

View File

@@ -1,6 +1,7 @@
import Badge from '@app/components/Common/Badge'; import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import LibraryItem from '@app/components/Settings/LibraryItem'; import LibraryItem from '@app/components/Settings/LibraryItem';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
@@ -30,13 +31,14 @@ const messages = defineMessages('components.Settings', {
jellyfinSettingsSuccess: '{mediaServerName} settings saved successfully!', jellyfinSettingsSuccess: '{mediaServerName} settings saved successfully!',
jellyfinSettings: '{mediaServerName} Settings', jellyfinSettings: '{mediaServerName} Settings',
jellyfinSettingsDescription: jellyfinSettingsDescription:
'Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page.', 'Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page. You can also change the Jellyfin API key, which was automatically generated previously.',
externalUrl: 'External URL', externalUrl: 'External URL',
hostname: 'Hostname or IP Address', hostname: 'Hostname or IP Address',
port: 'Port', port: 'Port',
enablessl: 'Use SSL', enablessl: 'Use SSL',
urlBase: 'URL Base', urlBase: 'URL Base',
jellyfinForgotPasswordUrl: 'Forgot Password URL', jellyfinForgotPasswordUrl: 'Forgot Password URL',
apiKey: 'API key',
jellyfinSyncFailedNoLibrariesFound: 'No libraries were found', jellyfinSyncFailedNoLibrariesFound: 'No libraries were found',
jellyfinSyncFailedAutomaticGroupedFolders: jellyfinSyncFailedAutomaticGroupedFolders:
'Custom authentication with Automatic Library Grouping not supported', 'Custom authentication with Automatic Library Grouping not supported',
@@ -444,119 +446,121 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
</div> </div>
</div> </div>
</div> </div>
{showAdvancedSettings && ( <div className="mt-10 mb-6">
<> <h3 className="heading">
<div className="mt-10 mb-6"> {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
<h3 className="heading"> ? intl.formatMessage(messages.jellyfinSettings, {
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby' mediaServerName: 'Emby',
? intl.formatMessage(messages.jellyfinSettings, { })
mediaServerName: 'Emby', : intl.formatMessage(messages.jellyfinSettings, {
}) mediaServerName: 'Jellyfin',
: intl.formatMessage(messages.jellyfinSettings, { })}
mediaServerName: 'Jellyfin', </h3>
})} <p className="description">
</h3> {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
<p className="description"> ? intl.formatMessage(messages.jellyfinSettingsDescription, {
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby' mediaServerName: 'Emby',
? intl.formatMessage(messages.jellyfinSettingsDescription, { })
mediaServerName: 'Emby', : intl.formatMessage(messages.jellyfinSettingsDescription, {
}) mediaServerName: 'Jellyfin',
: intl.formatMessage(messages.jellyfinSettingsDescription, { })}
mediaServerName: 'Jellyfin', </p>
})} </div>
</p> <Formik
</div> initialValues={{
<Formik hostname: data?.ip,
initialValues={{ port: data?.port ?? 8096,
hostname: data?.ip, useSsl: data?.useSsl,
port: data?.port ?? 8096, urlBase: data?.urlBase || '',
useSsl: data?.useSsl, jellyfinExternalUrl: data?.externalHostname || '',
urlBase: data?.urlBase || '', jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
jellyfinExternalUrl: data?.externalHostname || '', apiKey: data?.apiKey,
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '', }}
}} validationSchema={JellyfinSettingsSchema}
validationSchema={JellyfinSettingsSchema} onSubmit={async (values) => {
onSubmit={async (values) => { try {
try { const res = await fetch('/api/v1/settings/jellyfin', {
const res = await fetch('/api/v1/settings/jellyfin', { method: 'POST',
method: 'POST', headers: {
headers: { 'Content-Type': 'application/json',
'Content-Type': 'application/json', },
}, body: JSON.stringify({
body: JSON.stringify({ ip: values.hostname,
ip: values.hostname, port: Number(values.port),
port: Number(values.port), useSsl: values.useSsl,
useSsl: values.useSsl, urlBase: values.urlBase,
urlBase: values.urlBase, externalHostname: values.jellyfinExternalUrl,
externalHostname: values.jellyfinExternalUrl, jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl, apiKey: values.apiKey,
} as JellyfinSettings), } as JellyfinSettings),
}); });
if (!res.ok) throw new Error(res.statusText, { cause: res }); if (!res.ok) throw new Error(res.statusText, { cause: res });
addToast( addToast(
intl.formatMessage(messages.jellyfinSettingsSuccess, { intl.formatMessage(messages.jellyfinSettingsSuccess, {
mediaServerName: mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby' ? 'Emby'
: 'Jellyfin', : 'Jellyfin',
}), }),
{ {
autoDismiss: true, autoDismiss: true,
appearance: 'success', appearance: 'success',
}
);
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
if (errorData?.message === ApiErrorCode.InvalidUrl) {
addToast(
intl.formatMessage(messages.invalidurlerror, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
appearance: 'error',
}
);
} else {
addToast(
intl.formatMessage(messages.jellyfinSettingsFailure, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
appearance: 'error',
}
);
}
} finally {
revalidate();
} }
}} );
> } catch (e) {
{({ let errorData;
errors, try {
touched, errorData = await e.cause?.text();
values, errorData = JSON.parse(errorData);
setFieldValue, } catch {
handleSubmit, /* empty */
isSubmitting, }
isValid, if (errorData?.message === ApiErrorCode.InvalidUrl) {
}) => { addToast(
return ( intl.formatMessage(messages.invalidurlerror, {
<form className="section" onSubmit={handleSubmit}> mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
appearance: 'error',
}
);
} else {
addToast(
intl.formatMessage(messages.jellyfinSettingsFailure, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
appearance: 'error',
}
);
}
} finally {
revalidate();
}
}}
>
{({
errors,
touched,
values,
setFieldValue,
handleSubmit,
isSubmitting,
isValid,
}) => {
return (
<form className="section" onSubmit={handleSubmit}>
{showAdvancedSettings && (
<>
<div className="form-row"> <div className="form-row">
<label htmlFor="hostname" className="text-label"> <label htmlFor="hostname" className="text-label">
{intl.formatMessage(messages.hostname)} {intl.formatMessage(messages.hostname)}
@@ -638,75 +642,92 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="form-row"> </>
<label htmlFor="jellyfinExternalUrl" className="text-label"> )}
{intl.formatMessage(messages.externalUrl)} <div className="form-row">
</label> <label htmlFor="jellyfinExternalUrl" className="text-label">
<div className="form-input-area"> {intl.formatMessage(messages.externalUrl)}
<div className="form-input-field"> </label>
<Field <div className="form-input-area">
type="text" <div className="form-input-field">
inputMode="url" <Field
id="jellyfinExternalUrl" type="text"
name="jellyfinExternalUrl" inputMode="url"
/> id="jellyfinExternalUrl"
</div> name="jellyfinExternalUrl"
{errors.jellyfinExternalUrl && />
touched.jellyfinExternalUrl && (
<div className="error">
{errors.jellyfinExternalUrl}
</div>
)}
</div>
</div> </div>
<div className="form-row"> {errors.jellyfinExternalUrl &&
<label touched.jellyfinExternalUrl && (
htmlFor="jellyfinForgotPasswordUrl" <div className="error">{errors.jellyfinExternalUrl}</div>
className="text-label" )}
</div>
</div>
<div className="form-row">
<label
htmlFor="jellyfinForgotPasswordUrl"
className="text-label"
>
{intl.formatMessage(messages.jellyfinForgotPasswordUrl)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
type="text"
inputMode="url"
id="jellyfinForgotPasswordUrl"
name="jellyfinForgotPasswordUrl"
/>
</div>
{errors.jellyfinForgotPasswordUrl &&
touched.jellyfinForgotPasswordUrl && (
<div className="error">
{errors.jellyfinForgotPasswordUrl}
</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="apiKey" className="text-label">
{intl.formatMessage(messages.apiKey)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
as="field"
type="text"
inputMode="url"
id="apiKey"
name="apiKey"
/>
</div>
{errors.apiKey && touched.apiKey && (
<div className="error">{errors.apiKey}</div>
)}
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
> >
{intl.formatMessage(messages.jellyfinForgotPasswordUrl)} <ArrowDownOnSquareIcon />
</label> <span>
<div className="form-input-area"> {isSubmitting
<div className="form-input-field"> ? intl.formatMessage(globalMessages.saving)
<Field : intl.formatMessage(globalMessages.save)}
type="text"
inputMode="url"
id="jellyfinForgotPasswordUrl"
name="jellyfinForgotPasswordUrl"
/>
</div>
{errors.jellyfinForgotPasswordUrl &&
touched.jellyfinForgotPasswordUrl && (
<div className="error">
{errors.jellyfinForgotPasswordUrl}
</div>
)}
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
</span>
</Button>
</span> </span>
</div> </Button>
</div> </span>
</form> </div>
); </div>
}} </form>
</Formik> );
</> }}
)} </Formik>
</> </>
); );
}; };

View File

@@ -950,7 +950,7 @@
"components.Settings.is4k": "4K", "components.Settings.is4k": "4K",
"components.Settings.jellyfinForgotPasswordUrl": "Forgot Password URL", "components.Settings.jellyfinForgotPasswordUrl": "Forgot Password URL",
"components.Settings.jellyfinSettings": "{mediaServerName} Settings", "components.Settings.jellyfinSettings": "{mediaServerName} Settings",
"components.Settings.jellyfinSettingsDescription": "Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page.", "components.Settings.jellyfinSettingsDescription": "Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page. You can also change the Jellyfin API key, which was automatically generated previously.",
"components.Settings.jellyfinSettingsFailure": "Something went wrong while saving {mediaServerName} settings.", "components.Settings.jellyfinSettingsFailure": "Something went wrong while saving {mediaServerName} settings.",
"components.Settings.jellyfinSettingsSuccess": "{mediaServerName} settings saved successfully!", "components.Settings.jellyfinSettingsSuccess": "{mediaServerName} settings saved successfully!",
"components.Settings.jellyfinSyncFailedAutomaticGroupedFolders": "Custom authentication with Automatic Library Grouping not supported", "components.Settings.jellyfinSyncFailedAutomaticGroupedFolders": "Custom authentication with Automatic Library Grouping not supported",