From bd4da6d5fc8cb55c2bc3d9a8336787cbd30814d0 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Tue, 13 Aug 2024 16:01:45 +0200 Subject: [PATCH] feat(jellyfinapi): switch to API tokens instead of auth tokens (#868) * feat(jellyfinapi): create Jellyfin API key from admin user * fix(jellyfinapi): add migration script for Jellyfin API key * feat(jellyfinapi): use Jellyfin API key instead of admin auth token * fix(jellyfinapi): fix api key migration * feat(jellyfinapi): add API key field to Jellyfin settings * fix: move the API key field in the Jellyfin settings --- server/api/externalapi.ts | 11 +- server/api/jellyfin.ts | 22 +- server/index.ts | 2 +- server/lib/availabilitySync.ts | 9 +- server/lib/scanners/jellyfin/index.ts | 9 +- server/lib/settings/index.ts | 12 +- .../migrations/0002_migrate_apitokens.ts | 36 ++ server/lib/settings/migrator.ts | 16 +- server/routes/auth.ts | 15 +- server/routes/settings/index.ts | 15 +- server/routes/user/index.ts | 14 +- src/components/Settings/SettingsJellyfin.tsx | 381 ++++++++++-------- src/i18n/locale/en.json | 2 +- 13 files changed, 309 insertions(+), 235 deletions(-) create mode 100644 server/lib/settings/migrations/0002_migrate_apitokens.ts diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index 2788db1e1..4f0ded026 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -85,7 +85,7 @@ class ExternalAPI { protected async post( endpoint: string, - data: Record, + data?: Record, params?: Record, ttl?: number, config?: RequestInit @@ -107,7 +107,7 @@ class ExternalAPI { ...this.defaultHeaders, ...config?.headers, }, - body: JSON.stringify(data), + body: data ? JSON.stringify(data) : undefined, }); if (!response.ok) { const text = await response.text(); @@ -286,7 +286,12 @@ class ExternalAPI { ...this.params, ...params, }); - return `${href}?${searchParams.toString()}`; + return ( + href + + (searchParams.toString().length + ? '?' + searchParams.toString() + : searchParams.toString()) + ); } private serializeCacheKey( diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index ff5144cec..f65503477 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -93,9 +93,7 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem { } class JellyfinAPI extends ExternalAPI { - private authToken?: string; private userId?: string; - private jellyfinHost: string; constructor(jellyfinHost: string, authToken?: string, deviceId?: string) { let authHeaderVal: string; @@ -114,9 +112,6 @@ class JellyfinAPI extends ExternalAPI { }, } ); - - this.jellyfinHost = jellyfinHost; - this.authToken = authToken; } public async login( @@ -405,6 +400,23 @@ class JellyfinAPI extends ExternalAPI { throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); } } + + public async createApiToken(appName: string): Promise { + try { + await this.post(`/Auth/Keys?App=${appName}`); + const apiKeys = await this.get(`/Auth/Keys`); + return apiKeys.Items.reverse().find( + (item: any) => item.AppName === appName + ).AccessToken; + } catch (e) { + logger.error( + `Something went wrong while creating an API key the Jellyfin server: ${e.message}`, + { label: 'Jellyfin API' } + ); + + throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); + } + } } export default JellyfinAPI; diff --git a/server/index.ts b/server/index.ts index 2d90f05c3..ef20674da 100644 --- a/server/index.ts +++ b/server/index.ts @@ -63,7 +63,7 @@ app } // Load Settings - const settings = getSettings(); + const settings = await getSettings().load(); restartFlag.initializeSettings(settings.main); // Migrate library types diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 4ecd91073..b85d29e47 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -63,12 +63,7 @@ class AvailabilitySync { ) { admin = await userRepository.findOne({ where: { id: 1 }, - select: [ - 'id', - 'jellyfinAuthToken', - 'jellyfinUserId', - 'jellyfinDeviceId', - ], + select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'], order: { id: 'ASC' }, }); } @@ -86,7 +81,7 @@ class AvailabilitySync { if (admin) { this.jellyfinClient = new JellyfinAPI( getHostname(), - admin.jellyfinAuthToken, + settings.jellyfin.apiKey, admin.jellyfinDeviceId ); diff --git a/server/lib/scanners/jellyfin/index.ts b/server/lib/scanners/jellyfin/index.ts index baa8d963f..4ccf54850 100644 --- a/server/lib/scanners/jellyfin/index.ts +++ b/server/lib/scanners/jellyfin/index.ts @@ -582,12 +582,7 @@ class JellyfinScanner { const userRepository = getRepository(User); const admin = await userRepository.findOne({ where: { id: 1 }, - select: [ - 'id', - 'jellyfinAuthToken', - 'jellyfinUserId', - 'jellyfinDeviceId', - ], + select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'], order: { id: 'ASC' }, }); @@ -597,7 +592,7 @@ class JellyfinScanner { this.jfClient = new JellyfinAPI( getHostname(), - admin.jellyfinAuthToken, + settings.jellyfin.apiKey, admin.jellyfinDeviceId ); diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 7c117c110..8c55d6c3d 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -47,6 +47,7 @@ export interface JellyfinSettings { jellyfinForgotPasswordUrl?: string; libraries: Library[]; serverId: string; + apiKey: string; } export interface TautulliSettings { hostname?: string; @@ -342,6 +343,7 @@ class Settings { jellyfinForgotPasswordUrl: '', libraries: [], serverId: '', + apiKey: '', }, tautulli: {}, radarr: [], @@ -629,7 +631,7 @@ class Settings { * @param overrideSettings If passed in, will override all existing settings with these * values */ - public load(overrideSettings?: AllSettings): Settings { + public async load(overrideSettings?: AllSettings): Promise { if (overrideSettings) { this.data = overrideSettings; return this; @@ -642,7 +644,7 @@ class Settings { if (data) { const parsedJson = JSON.parse(data); - this.data = runMigrations(parsedJson); + this.data = await runMigrations(parsedJson); this.data = merge(this.data, parsedJson); @@ -656,7 +658,6 @@ class Settings { } } -let loaded = false; let settings: Settings | undefined; export const getSettings = (initialSettings?: AllSettings): Settings => { @@ -664,11 +665,6 @@ export const getSettings = (initialSettings?: AllSettings): Settings => { settings = new Settings(initialSettings); } - if (!loaded) { - settings.load(); - loaded = true; - } - return settings; }; diff --git a/server/lib/settings/migrations/0002_migrate_apitokens.ts b/server/lib/settings/migrations/0002_migrate_apitokens.ts new file mode 100644 index 000000000..46340433b --- /dev/null +++ b/server/lib/settings/migrations/0002_migrate_apitokens.ts @@ -0,0 +1,36 @@ +import JellyfinAPI from '@server/api/jellyfin'; +import { MediaServerType } from '@server/constants/server'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import type { AllSettings } from '@server/lib/settings'; +import { getHostname } from '@server/utils/getHostname'; + +const migrateApiTokens = async (settings: any): Promise => { + const mediaServerType = settings.main.mediaServerType; + if ( + !settings.jellyfin.apiKey && + (mediaServerType === MediaServerType.JELLYFIN || + mediaServerType === MediaServerType.EMBY) + ) { + const userRepository = getRepository(User); + const admin = await userRepository.findOne({ + where: { id: 1 }, + select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'], + order: { id: 'ASC' }, + }); + if (!admin) { + return settings; + } + const jellyfinClient = new JellyfinAPI( + getHostname(settings.jellyfin), + admin.jellyfinAuthToken, + admin.jellyfinDeviceId + ); + jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); + const apiKey = await jellyfinClient.createApiToken('Jellyseerr'); + settings.jellyfin.apiKey = apiKey; + } + return settings; +}; + +export default migrateApiTokens; diff --git a/server/lib/settings/migrator.ts b/server/lib/settings/migrator.ts index 9d709590d..856016e11 100644 --- a/server/lib/settings/migrator.ts +++ b/server/lib/settings/migrator.ts @@ -1,10 +1,13 @@ import type { AllSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import fs from 'fs'; import path from 'path'; const migrationsDir = path.join(__dirname, 'migrations'); -export const runMigrations = (settings: AllSettings): AllSettings => { +export const runMigrations = async ( + settings: AllSettings +): Promise => { const migrations = fs .readdirSync(migrationsDir) .filter((file) => file.endsWith('.js') || file.endsWith('.ts')) @@ -13,8 +16,15 @@ export const runMigrations = (settings: AllSettings): AllSettings => { let migrated = settings; - for (const migration of migrations) { - migrated = migration(migrated); + try { + for (const migration of migrations) { + migrated = await migration(migrated); + } + } catch (e) { + logger.error( + `Something went wrong while running settings migrations: ${e.message}`, + { label: 'Settings Migrator' } + ); } return migrated; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 966dc2694..6f01135de 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -324,7 +324,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinUsername: account.User.Name, jellyfinUserId: account.User.Id, jellyfinDeviceId: deviceId, - jellyfinAuthToken: account.AccessToken, permissions: Permission.ADMIN, avatar: account.User.PrimaryImageTag ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` @@ -335,6 +334,14 @@ authRoutes.post('/jellyfin', async (req, res, next) => { 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(); settings.jellyfin.name = serverName; @@ -343,6 +350,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { settings.jellyfin.port = body.port ?? 8096; settings.jellyfin.urlBase = body.urlBase ?? ''; settings.jellyfin.useSsl = body.useSsl ?? false; + settings.jellyfin.apiKey = apiKey; settings.save(); startJobs(); @@ -366,10 +374,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinUsername: account.User.Name, } ); - // Let's check if their authtoken is up to date - if (user.jellyfinAuthToken !== account.AccessToken) { - user.jellyfinAuthToken = account.AccessToken; - } // Update the users avatar with their jellyfin profile pic (incase it changed) if (account.User.PrimaryImageTag) { user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; @@ -421,7 +425,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinUsername: account.User.Name, jellyfinUserId: account.User.Id, jellyfinDeviceId: deviceId, - jellyfinAuthToken: account.AccessToken, permissions: settings.main.defaultPermissions, avatar: account.User.PrimaryImageTag ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 9e1a6220f..30898d2a1 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -262,7 +262,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => { try { const admin = await userRepository.findOneOrFail({ where: { id: 1 }, - select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'], + select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'], order: { id: 'ASC' }, }); @@ -270,7 +270,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => { const jellyfinClient = new JellyfinAPI( getHostname(tempJellyfinSettings), - admin.jellyfinAuthToken ?? '', + tempJellyfinSettings.apiKey, admin.jellyfinDeviceId ?? '' ); @@ -318,13 +318,13 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => { if (req.query.sync) { const userRepository = getRepository(User); const admin = await userRepository.findOneOrFail({ - select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'], + select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'], where: { id: 1 }, order: { id: 'ASC' }, }); const jellyfinClient = new JellyfinAPI( getHostname(), - admin.jellyfinAuthToken ?? '', + settings.jellyfin.apiKey, admin.jellyfinDeviceId ?? '' ); @@ -376,7 +376,8 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => { }); settingsRoutes.get('/jellyfin/users', async (req, res) => { - const { externalHostname } = getSettings().jellyfin; + const settings = getSettings(); + const { externalHostname } = settings.jellyfin; const jellyfinHost = externalHostname && externalHostname.length > 0 ? externalHostname @@ -384,13 +385,13 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => { const userRepository = getRepository(User); const admin = await userRepository.findOneOrFail({ - select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'], + select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'], where: { id: 1 }, order: { id: 'ASC' }, }); const jellyfinClient = new JellyfinAPI( getHostname(), - admin.jellyfinAuthToken ?? '', + settings.jellyfin.apiKey, admin.jellyfinDeviceId ?? '' ); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 016709c60..da9b649cd 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -501,17 +501,14 @@ router.post( // taken from auth.ts const admin = await userRepository.findOneOrFail({ where: { id: 1 }, - select: [ - 'id', - 'jellyfinAuthToken', - 'jellyfinDeviceId', - 'jellyfinUserId', - ], + select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'], order: { id: 'ASC' }, }); + + const hostname = getHostname(); const jellyfinClient = new JellyfinAPI( - getHostname(), - admin.jellyfinAuthToken ?? '', + hostname, + settings.jellyfin.apiKey, admin.jellyfinDeviceId ?? '' ); jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); @@ -519,7 +516,6 @@ router.post( //const jellyfinUsersResponse = await jellyfinClient.getUsers(); const createdUsers: User[] = []; const { externalHostname } = getSettings().jellyfin; - const hostname = getHostname(); const jellyfinHost = externalHostname && externalHostname.length > 0 diff --git a/src/components/Settings/SettingsJellyfin.tsx b/src/components/Settings/SettingsJellyfin.tsx index 91c44e12f..a627f6d31 100644 --- a/src/components/Settings/SettingsJellyfin.tsx +++ b/src/components/Settings/SettingsJellyfin.tsx @@ -1,6 +1,7 @@ import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import SensitiveInput from '@app/components/Common/SensitiveInput'; import LibraryItem from '@app/components/Settings/LibraryItem'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; @@ -30,13 +31,14 @@ const messages = defineMessages('components.Settings', { jellyfinSettingsSuccess: '{mediaServerName} settings saved successfully!', jellyfinSettings: '{mediaServerName} 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.', + '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', hostname: 'Hostname or IP Address', port: 'Port', enablessl: 'Use SSL', urlBase: 'URL Base', jellyfinForgotPasswordUrl: 'Forgot Password URL', + apiKey: 'API key', jellyfinSyncFailedNoLibrariesFound: 'No libraries were found', jellyfinSyncFailedAutomaticGroupedFolders: 'Custom authentication with Automatic Library Grouping not supported', @@ -444,119 +446,121 @@ const SettingsJellyfin: React.FC = ({ - {showAdvancedSettings && ( - <> -
-

- {publicRuntimeConfig.JELLYFIN_TYPE == 'emby' - ? intl.formatMessage(messages.jellyfinSettings, { - mediaServerName: 'Emby', - }) - : intl.formatMessage(messages.jellyfinSettings, { - mediaServerName: 'Jellyfin', - })} -

-

- {publicRuntimeConfig.JELLYFIN_TYPE == 'emby' - ? intl.formatMessage(messages.jellyfinSettingsDescription, { - mediaServerName: 'Emby', - }) - : intl.formatMessage(messages.jellyfinSettingsDescription, { - mediaServerName: 'Jellyfin', - })} -

-
- { - try { - const res = await fetch('/api/v1/settings/jellyfin', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - ip: values.hostname, - port: Number(values.port), - useSsl: values.useSsl, - urlBase: values.urlBase, - externalHostname: values.jellyfinExternalUrl, - jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl, - } as JellyfinSettings), - }); - if (!res.ok) throw new Error(res.statusText, { cause: res }); +
+

+ {publicRuntimeConfig.JELLYFIN_TYPE == 'emby' + ? intl.formatMessage(messages.jellyfinSettings, { + mediaServerName: 'Emby', + }) + : intl.formatMessage(messages.jellyfinSettings, { + mediaServerName: 'Jellyfin', + })} +

+

+ {publicRuntimeConfig.JELLYFIN_TYPE == 'emby' + ? intl.formatMessage(messages.jellyfinSettingsDescription, { + mediaServerName: 'Emby', + }) + : intl.formatMessage(messages.jellyfinSettingsDescription, { + mediaServerName: 'Jellyfin', + })} +

+
+ { + try { + const res = await fetch('/api/v1/settings/jellyfin', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ip: values.hostname, + port: Number(values.port), + useSsl: values.useSsl, + urlBase: values.urlBase, + externalHostname: values.jellyfinExternalUrl, + jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl, + apiKey: values.apiKey, + } as JellyfinSettings), + }); + if (!res.ok) throw new Error(res.statusText, { cause: res }); - addToast( - intl.formatMessage(messages.jellyfinSettingsSuccess, { - mediaServerName: - publicRuntimeConfig.JELLYFIN_TYPE == 'emby' - ? 'Emby' - : 'Jellyfin', - }), - { - autoDismiss: true, - 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(); + addToast( + intl.formatMessage(messages.jellyfinSettingsSuccess, { + mediaServerName: + publicRuntimeConfig.JELLYFIN_TYPE == 'emby' + ? 'Emby' + : 'Jellyfin', + }), + { + autoDismiss: true, + appearance: 'success', } - }} - > - {({ - errors, - touched, - values, - setFieldValue, - handleSubmit, - isSubmitting, - isValid, - }) => { - return ( -
+ ); + } 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(); + } + }} + > + {({ + errors, + touched, + values, + setFieldValue, + handleSubmit, + isSubmitting, + isValid, + }) => { + return ( + + {showAdvancedSettings && ( + <>
+ + )} +
+ +
+
+ +
+ {errors.apiKey && touched.apiKey && ( +
{errors.apiKey}
+ )} +
+
+ {showAdvancedSettings && ( + <>
-
- -
-
- -
- {errors.jellyfinExternalUrl && - touched.jellyfinExternalUrl && ( -
- {errors.jellyfinExternalUrl} -
- )} -
+ + )} +
+ +
+
+
-
-
+ )} +
+
+
+ +
+
+ +
+ {errors.jellyfinForgotPasswordUrl && + touched.jellyfinForgotPasswordUrl && ( +
+ {errors.jellyfinForgotPasswordUrl} +
+ )} +
+
+
+
+ +
-
-
- - + + + {isSubmitting + ? intl.formatMessage(globalMessages.saving) + : intl.formatMessage(globalMessages.save)} -
-
- - ); - }} - - - )} + + +
+
+ + ); + }} +
); }; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index e24e2dc56..f34aa5b40 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -955,7 +955,7 @@ "components.Settings.is4k": "4K", "components.Settings.jellyfinForgotPasswordUrl": "Forgot Password URL", "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.jellyfinSettingsSuccess": "{mediaServerName} settings saved successfully!", "components.Settings.jellyfinSyncFailedAutomaticGroupedFolders": "Custom authentication with Automatic Library Grouping not supported",