Skip to content

Commit

Permalink
feat(jellyfinapi): switch to API tokens instead of auth tokens (#868)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
gauthier-th authored Aug 13, 2024
1 parent 12f908d commit bd4da6d
Show file tree
Hide file tree
Showing 13 changed files with 308 additions and 234 deletions.
11 changes: 8 additions & 3 deletions server/api/externalapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -286,7 +286,12 @@ class ExternalAPI {
...this.params,
...params,
});
return `${href}?${searchParams.toString()}`;
return (
href +
(searchParams.toString().length
? '?' + searchParams.toString()
: searchParams.toString())
);
}

private serializeCacheKey(
Expand Down
22 changes: 17 additions & 5 deletions server/api/jellyfin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -114,9 +112,6 @@ class JellyfinAPI extends ExternalAPI {
},
}
);

this.jellyfinHost = jellyfinHost;
this.authToken = authToken;
}

public async login(
Expand Down Expand Up @@ -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;
2 changes: 1 addition & 1 deletion server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ app
}

// Load Settings
const settings = getSettings();
const settings = await getSettings().load();
restartFlag.initializeSettings(settings.main);

// Migrate library types
Expand Down
9 changes: 2 additions & 7 deletions server/lib/availabilitySync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
});
}
Expand All @@ -86,7 +81,7 @@ class AvailabilitySync {
if (admin) {
this.jellyfinClient = new JellyfinAPI(
getHostname(),
admin.jellyfinAuthToken,
settings.jellyfin.apiKey,
admin.jellyfinDeviceId
);

Expand Down
9 changes: 2 additions & 7 deletions server/lib/scanners/jellyfin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
});

Expand All @@ -597,7 +592,7 @@ class JellyfinScanner {

this.jfClient = new JellyfinAPI(
getHostname(),
admin.jellyfinAuthToken,
settings.jellyfin.apiKey,
admin.jellyfinDeviceId
);

Expand Down
12 changes: 4 additions & 8 deletions server/lib/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export interface JellyfinSettings {
jellyfinForgotPasswordUrl?: string;
libraries: Library[];
serverId: string;
apiKey: string;
}
export interface TautulliSettings {
hostname?: string;
Expand Down Expand Up @@ -342,6 +343,7 @@ class Settings {
jellyfinForgotPasswordUrl: '',
libraries: [],
serverId: '',
apiKey: '',
},
tautulli: {},
radarr: [],
Expand Down Expand Up @@ -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;
Expand All @@ -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);

Expand All @@ -656,19 +658,13 @@ class Settings {
}
}

let loaded = false;
let settings: Settings | undefined;

export const getSettings = (initialSettings?: AllSettings): Settings => {
if (!settings) {
settings = new Settings(initialSettings);
}

if (!loaded) {
settings.load();
loaded = true;
}

return settings;
};

Expand Down
36 changes: 36 additions & 0 deletions server/lib/settings/migrations/0002_migrate_apitokens.ts
Original file line number Diff line number Diff line change
@@ -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;
16 changes: 13 additions & 3 deletions server/lib/settings/migrator.ts
Original file line number Diff line number Diff line change
@@ -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'))
Expand All @@ -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;
Expand Down
15 changes: 9 additions & 6 deletions server/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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;
Expand All @@ -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();

Expand All @@ -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`;
Expand Down Expand Up @@ -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`
Expand Down
15 changes: 8 additions & 7 deletions server/routes/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,15 +262,15 @@ 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' },
});

const tempJellyfinSettings = { ...settings.jellyfin, ...req.body };

const jellyfinClient = new JellyfinAPI(
getHostname(tempJellyfinSettings),
admin.jellyfinAuthToken ?? '',
tempJellyfinSettings.apiKey,
admin.jellyfinDeviceId ?? ''
);

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

Expand Down Expand Up @@ -376,21 +376,22 @@ 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
: getHostname();

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

Expand Down
14 changes: 5 additions & 9 deletions server/routes/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,25 +501,21 @@ 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 ?? '');

//const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = [];
const { externalHostname } = getSettings().jellyfin;
const hostname = getHostname();

const jellyfinHost =
externalHostname && externalHostname.length > 0
Expand Down
Loading

0 comments on commit bd4da6d

Please sign in to comment.