Compare commits

...

6 Commits

13 changed files with 309 additions and 235 deletions

View File

@@ -85,7 +85,7 @@ class ExternalAPI {
protected async post<T>(
endpoint: string,
data: Record<string, unknown>,
data?: Record<string, unknown>,
params?: Record<string, string>,
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(

View File

@@ -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<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;

View File

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

View File

@@ -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
);

View File

@@ -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
);

View File

@@ -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<Settings> {
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;
};

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 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<AllSettings> => {
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;
try {
for (const migration of migrations) {
migrated = migration(migrated);
migrated = await migration(migrated);
}
} catch (e) {
logger.error(
`Something went wrong while running settings migrations: ${e.message}`,
{ label: 'Settings Migrator' }
);
}
return migrated;

View File

@@ -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`

View File

@@ -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 ?? ''
);

View File

@@ -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

View File

@@ -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,8 +446,6 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
</div>
</div>
</div>
{showAdvancedSettings && (
<>
<div className="mt-10 mb-6">
<h3 className="heading">
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
@@ -474,6 +474,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
urlBase: data?.urlBase || '',
jellyfinExternalUrl: data?.externalHostname || '',
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
apiKey: data?.apiKey,
}}
validationSchema={JellyfinSettingsSchema}
onSubmit={async (values) => {
@@ -490,6 +491,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
urlBase: values.urlBase,
externalHostname: values.jellyfinExternalUrl,
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
apiKey: values.apiKey,
} as JellyfinSettings),
});
if (!res.ok) throw new Error(res.statusText, { cause: res });
@@ -557,6 +559,8 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
}) => {
return (
<form className="section" onSubmit={handleSubmit}>
{showAdvancedSettings && (
<>
<div className="form-row">
<label htmlFor="hostname" className="text-label">
{intl.formatMessage(messages.hostname)}
@@ -618,6 +622,29 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
/>
</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">
<label htmlFor="urlBase" className="text-label">
{intl.formatMessage(messages.urlBase)}
@@ -638,6 +665,8 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
)}
</div>
</div>
</>
)}
<div className="form-row">
<label htmlFor="jellyfinExternalUrl" className="text-label">
{intl.formatMessage(messages.externalUrl)}
@@ -653,9 +682,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
</div>
{errors.jellyfinExternalUrl &&
touched.jellyfinExternalUrl && (
<div className="error">
{errors.jellyfinExternalUrl}
</div>
<div className="error">{errors.jellyfinExternalUrl}</div>
)}
</div>
</div>
@@ -706,8 +733,6 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
}}
</Formik>
</>
)}
</>
);
};

View File

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