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
This commit is contained in:
Gauthier
2024-08-13 16:01:45 +02:00
committed by GitHub
parent 12f908de7f
commit bd4da6d5fc
13 changed files with 309 additions and 235 deletions

View File

@@ -85,7 +85,7 @@ class ExternalAPI {
protected async post<T>( protected async post<T>(
endpoint: string, endpoint: string,
data: Record<string, unknown>, data?: Record<string, unknown>,
params?: Record<string, string>, params?: Record<string, string>,
ttl?: number, ttl?: number,
config?: RequestInit config?: RequestInit
@@ -107,7 +107,7 @@ class ExternalAPI {
...this.defaultHeaders, ...this.defaultHeaders,
...config?.headers, ...config?.headers,
}, },
body: JSON.stringify(data), body: data ? JSON.stringify(data) : undefined,
}); });
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
@@ -286,7 +286,12 @@ class ExternalAPI {
...this.params, ...this.params,
...params, ...params,
}); });
return `${href}?${searchParams.toString()}`; return (
href +
(searchParams.toString().length
? '?' + searchParams.toString()
: searchParams.toString())
);
} }
private serializeCacheKey( private serializeCacheKey(

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(
@@ -405,6 +400,23 @@ class JellyfinAPI extends ExternalAPI {
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
public async createApiToken(appName: string): Promise<string> {
try {
await this.post(`/Auth/Keys?App=${appName}`);
const apiKeys = await this.get<any>(`/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; export default JellyfinAPI;

View File

@@ -63,7 +63,7 @@ app
} }
// Load Settings // Load Settings
const settings = getSettings(); const settings = await getSettings().load();
restartFlag.initializeSettings(settings.main); restartFlag.initializeSettings(settings.main);
// Migrate library types // Migrate library types

View File

@@ -63,12 +63,7 @@ class AvailabilitySync {
) { ) {
admin = await userRepository.findOne({ admin = await userRepository.findOne({
where: { id: 1 }, where: { id: 1 },
select: [ select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
'id',
'jellyfinAuthToken',
'jellyfinUserId',
'jellyfinDeviceId',
],
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
} }
@@ -86,7 +81,7 @@ class AvailabilitySync {
if (admin) { if (admin) {
this.jellyfinClient = new JellyfinAPI( this.jellyfinClient = new JellyfinAPI(
getHostname(), getHostname(),
admin.jellyfinAuthToken, settings.jellyfin.apiKey,
admin.jellyfinDeviceId admin.jellyfinDeviceId
); );

View File

@@ -582,12 +582,7 @@ class JellyfinScanner {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOne({ const admin = await userRepository.findOne({
where: { id: 1 }, where: { id: 1 },
select: [ select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
'id',
'jellyfinAuthToken',
'jellyfinUserId',
'jellyfinDeviceId',
],
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
@@ -597,7 +592,7 @@ class JellyfinScanner {
this.jfClient = new JellyfinAPI( this.jfClient = new JellyfinAPI(
getHostname(), getHostname(),
admin.jellyfinAuthToken, settings.jellyfin.apiKey,
admin.jellyfinDeviceId admin.jellyfinDeviceId
); );

View File

@@ -47,6 +47,7 @@ export interface JellyfinSettings {
jellyfinForgotPasswordUrl?: string; jellyfinForgotPasswordUrl?: string;
libraries: Library[]; libraries: Library[];
serverId: string; serverId: string;
apiKey: string;
} }
export interface TautulliSettings { export interface TautulliSettings {
hostname?: string; hostname?: string;
@@ -342,6 +343,7 @@ class Settings {
jellyfinForgotPasswordUrl: '', jellyfinForgotPasswordUrl: '',
libraries: [], libraries: [],
serverId: '', serverId: '',
apiKey: '',
}, },
tautulli: {}, tautulli: {},
radarr: [], radarr: [],
@@ -629,7 +631,7 @@ class Settings {
* @param overrideSettings If passed in, will override all existing settings with these * @param overrideSettings If passed in, will override all existing settings with these
* values * values
*/ */
public load(overrideSettings?: AllSettings): Settings { public async load(overrideSettings?: AllSettings): Promise<Settings> {
if (overrideSettings) { if (overrideSettings) {
this.data = overrideSettings; this.data = overrideSettings;
return this; return this;
@@ -642,7 +644,7 @@ class Settings {
if (data) { if (data) {
const parsedJson = JSON.parse(data); const parsedJson = JSON.parse(data);
this.data = runMigrations(parsedJson); this.data = await runMigrations(parsedJson);
this.data = merge(this.data, parsedJson); this.data = merge(this.data, parsedJson);
@@ -656,7 +658,6 @@ class Settings {
} }
} }
let loaded = false;
let settings: Settings | undefined; let settings: Settings | undefined;
export const getSettings = (initialSettings?: AllSettings): Settings => { export const getSettings = (initialSettings?: AllSettings): Settings => {
@@ -664,11 +665,6 @@ export const getSettings = (initialSettings?: AllSettings): Settings => {
settings = new Settings(initialSettings); settings = new Settings(initialSettings);
} }
if (!loaded) {
settings.load();
loaded = true;
}
return settings; return settings;
}; };

View File

@@ -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<AllSettings> => {
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;

View File

@@ -1,10 +1,13 @@
import type { AllSettings } from '@server/lib/settings'; import type { AllSettings } from '@server/lib/settings';
import logger from '@server/logger';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
const migrationsDir = path.join(__dirname, 'migrations'); const migrationsDir = path.join(__dirname, 'migrations');
export const runMigrations = (settings: AllSettings): AllSettings => { export const runMigrations = async (
settings: AllSettings
): Promise<AllSettings> => {
const migrations = fs const migrations = fs
.readdirSync(migrationsDir) .readdirSync(migrationsDir)
.filter((file) => file.endsWith('.js') || file.endsWith('.ts')) .filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
@@ -13,8 +16,15 @@ export const runMigrations = (settings: AllSettings): AllSettings => {
let migrated = settings; let migrated = settings;
for (const migration of migrations) { try {
migrated = migration(migrated); 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; return migrated;

View File

@@ -324,7 +324,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUsername: account.User.Name, jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id, jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId, jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN, permissions: Permission.ADMIN,
avatar: account.User.PrimaryImageTag avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` ? `${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, 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;
@@ -343,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();
@@ -366,10 +374,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUsername: account.User.Name, 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) // Update the users avatar with their jellyfin profile pic (incase it changed)
if (account.User.PrimaryImageTag) { if (account.User.PrimaryImageTag) {
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; 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, jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id, jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId, jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: settings.main.defaultPermissions, permissions: settings.main.defaultPermissions,
avatar: account.User.PrimaryImageTag avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`

View File

@@ -262,7 +262,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
try { try {
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
where: { id: 1 }, where: { id: 1 },
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'], select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
@@ -270,7 +270,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
getHostname(tempJellyfinSettings), getHostname(tempJellyfinSettings),
admin.jellyfinAuthToken ?? '', tempJellyfinSettings.apiKey,
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -318,13 +318,13 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
if (req.query.sync) { if (req.query.sync) {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'], select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
where: { id: 1 }, where: { id: 1 },
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
getHostname(), getHostname(),
admin.jellyfinAuthToken ?? '', settings.jellyfin.apiKey,
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -376,7 +376,8 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
}); });
settingsRoutes.get('/jellyfin/users', async (req, res) => { settingsRoutes.get('/jellyfin/users', async (req, res) => {
const { externalHostname } = getSettings().jellyfin; const settings = getSettings();
const { externalHostname } = settings.jellyfin;
const jellyfinHost = const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
@@ -384,13 +385,13 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'], select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
where: { id: 1 }, where: { id: 1 },
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
getHostname(), getHostname(),
admin.jellyfinAuthToken ?? '', settings.jellyfin.apiKey,
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );

View File

@@ -501,17 +501,14 @@ router.post(
// taken from auth.ts // taken from auth.ts
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
where: { id: 1 }, where: { id: 1 },
select: [ select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
'id',
'jellyfinAuthToken',
'jellyfinDeviceId',
'jellyfinUserId',
],
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const hostname = getHostname();
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
getHostname(), hostname,
admin.jellyfinAuthToken ?? '', settings.jellyfin.apiKey,
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
@@ -519,7 +516,6 @@ router.post(
//const jellyfinUsersResponse = await jellyfinClient.getUsers(); //const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = []; const createdUsers: User[] = [];
const { externalHostname } = getSettings().jellyfin; const { externalHostname } = getSettings().jellyfin;
const hostname = getHostname();
const jellyfinHost = const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0

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)}
@@ -618,6 +622,29 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
/> />
</div> </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>
{showAdvancedSettings && (
<>
<div className="form-row"> <div className="form-row">
<label htmlFor="urlBase" className="text-label"> <label htmlFor="urlBase" className="text-label">
{intl.formatMessage(messages.urlBase)} {intl.formatMessage(messages.urlBase)}
@@ -638,75 +665,73 @@ 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="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

@@ -955,7 +955,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",