mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-01 04:08:45 -05:00
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:
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
36
server/lib/settings/migrations/0002_migrate_apitokens.ts
Normal file
36
server/lib/settings/migrations/0002_migrate_apitokens.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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 ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user