refactor(jellyfinsettings): abstract jellyfin hostname, updated ui to reflect it, better validation

This PR refactors and abstracts jellyfin hostname into, jellyfin ip, jellyfin port, jellyfin useSsl,
and jellyfin urlBase. This makes it more consistent with how plex settings are stored as well. In
addition, this improves validation as validation can be applied seperately to them instead of as one
whole regex doing the work to validate the url.
UI was updated to reflect this.

BREAKING CHANGE: Jellyfin settings now does not include a hostname. Instead it abstracted it to ip,
port, useSsl, and urlBase. However, migration of old settings to new settings should work
automatically.
This commit is contained in:
fallenbagel
2024-05-25 05:44:05 +05:00
parent a791b53953
commit 68952a9239
12 changed files with 506 additions and 86 deletions

View File

@@ -98,8 +98,9 @@ class JellyfinAPI {
private jellyfinHost: string;
private axios: AxiosInstance;
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
this.jellyfinHost = jellyfinHost;
constructor(hostname: string, authToken?: string, deviceId?: string) {
this.jellyfinHost = hostname;
this.authToken = authToken;
let authHeaderVal = '';
@@ -175,6 +176,18 @@ class JellyfinAPI {
return;
}
public async getSystemInfo(): Promise<any> {
try {
// TODO: remove axios from here
const systemInfoResponse = await this.axios.get<any>('/System/Info');
return systemInfoResponse;
} catch (e) {
//TODO: Use the api error codes
throw new Error('Invalid auth token');
}
}
public async getServerName(): Promise<string> {
try {
const account = await this.axios.get<JellyfinUserResponse>(
@@ -220,6 +233,7 @@ class JellyfinAPI {
public async getLibraries(): Promise<JellyfinLibrary[]> {
try {
console.log('getting libraries with url', this.jellyfinHost);
const mediaFolders = await this.axios.get<any>(`/Library/MediaFolders`);
return this.mapLibraries(mediaFolders.data.Items);

View File

@@ -2,4 +2,7 @@ export enum ApiErrorCode {
InvalidUrl = 'INVALID_URL',
InvalidCredentials = 'INVALID_CREDENTIALS',
NotAdmin = 'NOT_ADMIN',
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
Generic = 'GENERIC',
}

View File

@@ -9,6 +9,7 @@ import type { DownloadingItem } from '@server/lib/downloadtracker';
import downloadTracker from '@server/lib/downloadtracker';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { getHostname } from '@server/utils/getHostname';
import {
AfterLoad,
Column,
@@ -211,15 +212,19 @@ class Media {
} else {
const pageName =
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
const { serverId, hostname, externalHostname } = getSettings().jellyfin;
let jellyfinHost =
const { serverId, externalHostname } = getSettings().jellyfin;
// let jellyfinHost =
// externalHostname && externalHostname.length > 0
// ? externalHostname
// : hostname;
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
: getHostname();
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
// jellyfinHost = jellyfinHost.endsWith('/')
// ? jellyfinHost.slice(0, -1)
// : jellyfinHost;
if (this.jellyfinMediaId) {
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;

View File

@@ -16,6 +16,7 @@ import { User } from '@server/entity/User';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { getHostname } from '@server/utils/getHostname';
class AvailabilitySync {
public running = false;
@@ -84,7 +85,8 @@ class AvailabilitySync {
) {
if (admin) {
this.jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
// settings.jellyfin. ?? '',
getHostname(),
admin.jellyfinAuthToken,
admin.jellyfinDeviceId
);

View File

@@ -12,6 +12,7 @@ import type { Library } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import AsyncLock from '@server/utils/asyncLock';
import { getHostname } from '@server/utils/getHostname';
import { randomUUID as uuid } from 'crypto';
import { uniqWith } from 'lodash';
@@ -590,8 +591,10 @@ class JellyfinScanner {
return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
}
const hostname = getHostname();
this.jfClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
hostname,
admin.jellyfinAuthToken,
admin.jellyfinDeviceId
);

View File

@@ -38,7 +38,10 @@ export interface PlexSettings {
export interface JellyfinSettings {
name: string;
hostname: string;
ip: string;
port: number;
useSsl?: boolean;
urlBase?: string;
externalHostname?: string;
jellyfinForgotPasswordUrl?: string;
libraries: Library[];
@@ -331,7 +334,9 @@ class Settings {
},
jellyfin: {
name: '',
hostname: '',
ip: '',
port: 8096,
useSsl: false,
externalHostname: '',
jellyfinForgotPasswordUrl: '',
libraries: [],
@@ -547,8 +552,8 @@ class Settings {
region: this.data.main.region,
originalLanguage: this.data.main.originalLanguage,
mediaServerType: this.main.mediaServerType,
jellyfinHost: this.jellyfin.hostname,
jellyfinExternalHost: this.jellyfin.externalHostname,
// jellyfinHost: this.jellyfin.hostname,
// jellyfinExternalHost: this.jellyfin.externalHostname,
partialRequestsEnabled: this.data.main.partialRequestsEnabled,
cacheImages: this.data.main.cacheImages,
vapidPublic: this.vapidPublic,
@@ -637,8 +642,60 @@ class Settings {
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
if (data) {
this.data = merge(this.data, JSON.parse(data));
this.save();
const oldJellyfinSettings = JSON.parse(data).jellyfin;
// Migrate old settings
// TODO: Test this migration and the regex
console.log(oldJellyfinSettings);
if (oldJellyfinSettings && oldJellyfinSettings.hostname) {
// migrate old jellyfin hostname to ip and port and useSsl
const hostname = oldJellyfinSettings.hostname;
const protocolMatch = hostname.match(/^(https?):\/\//i);
if (protocolMatch) {
this.data.jellyfin.useSsl = true;
}
const remainingUrl = hostname.replace(/^(https?):\/\//i, '');
const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/);
if (urlMatch) {
this.data.jellyfin.ip = urlMatch[1];
this.data.jellyfin.port = urlMatch[3] || '';
this.data.jellyfin.urlBase = urlMatch[4] || '';
if (!this.data.jellyfin.port && this.data.jellyfin.useSsl) {
this.data.jellyfin.port = 443;
}
if (
this.data.jellyfin.urlBase &&
this.data.jellyfin.urlBase.endsWith('/')
) {
this.data.jellyfin.urlBase = this.data.jellyfin.urlBase.slice(
0,
-1
);
}
}
delete oldJellyfinSettings.hostname;
console.log(this.data.jellyfin, oldJellyfinSettings.hostname);
this.data.jellyfin = Object.assign(
{},
this.data.jellyfin,
oldJellyfinSettings
);
this.save();
} else {
this.data = merge(this.data, JSON.parse(data));
this.save();
}
}
return this;
}

View File

@@ -11,6 +11,7 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { ApiError } from '@server/types/error';
import { getHostname } from '@server/utils/getHostname';
import * as EmailValidator from 'email-validator';
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
@@ -221,30 +222,39 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
username?: string;
password?: string;
hostname?: string;
port?: number;
urlBase?: string;
useSsl?: boolean;
email?: string;
};
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured
if (
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
settings.jellyfin.hostname !== ''
settings.jellyfin.ip !== ''
) {
return res.status(500).json({ error: 'Jellyfin login is disabled' });
} else if (!body.username) {
return res.status(500).json({ error: 'You must provide an username' });
} else if (settings.jellyfin.hostname !== '' && body.hostname) {
} else if (settings.jellyfin.ip !== '' && body.hostname) {
return res
.status(500)
.json({ error: 'Jellyfin hostname already configured' });
} else if (settings.jellyfin.hostname === '' && !body.hostname) {
} else if (settings.jellyfin.ip === '' && !body.hostname) {
return res.status(500).json({ error: 'No hostname provided.' });
}
try {
const hostname =
settings.jellyfin.hostname !== ''
? settings.jellyfin.hostname
: body.hostname ?? '';
settings.jellyfin.ip !== ''
? getHostname()
: getHostname({
useSsl: body.useSsl,
ip: body.hostname,
port: body.port,
urlBase: body.urlBase,
});
const { externalHostname } = getSettings().jellyfin;
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
@@ -260,17 +270,14 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
'base64'
);
}
// First we need to attempt to log the user in to jellyfin
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
let jellyfinHost =
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
const ip = req.ip ? req.ip.split(':').reverse()[0] : undefined;
const account = await jellyfinserver.login(
body.username,
@@ -314,7 +321,10 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
userType: UserType.JELLYFIN,
});
settings.jellyfin.hostname = body.hostname ?? '';
settings.jellyfin.ip = body.hostname ?? '';
settings.jellyfin.port = body.port ?? 8096;
settings.jellyfin.urlBase = body.urlBase ?? '';
settings.jellyfin.useSsl = body.useSsl ?? false;
settings.jellyfin.serverId = account.User.ServerId;
settings.save();
startJobs();
@@ -430,7 +440,9 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
label: 'Auth',
error: e.errorCode,
status: e.statusCode,
hostname: body.hostname,
hostname: `${body.useSsl ? 'https' : 'http'}://${body.hostname}:${
body.port
}${body.urlBase}`,
}
);
return next({

View File

@@ -2,6 +2,7 @@ import JellyfinAPI from '@server/api/jellyfin';
import PlexAPI from '@server/api/plexapi';
import PlexTvAPI from '@server/api/plextv';
import TautulliAPI from '@server/api/tautulli';
import { ApiErrorCode } from '@server/constants/error';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
@@ -26,6 +27,7 @@ import { isAuthenticated } from '@server/middleware/auth';
import discoverSettingRoutes from '@server/routes/settings/discover';
import { appDataPath } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import fs from 'fs';
@@ -252,11 +254,48 @@ settingsRoutes.get('/jellyfin', (_req, res) => {
res.status(200).json(settings.jellyfin);
});
settingsRoutes.post('/jellyfin', (req, res) => {
settingsRoutes.post('/jellyfin', async (req, res, next) => {
const userRepository = getRepository(User);
const settings = getSettings();
settings.jellyfin = merge(settings.jellyfin, req.body);
settings.save();
try {
const admin = await userRepository.findOneOrFail({
where: { id: 1 },
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' },
});
Object.assign(settings.jellyfin, req.body);
const jellyfinClient = new JellyfinAPI(
getHostname(),
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);
const result = await jellyfinClient.getSystemInfo();
console.log(result);
// TODO: use the apiErrorCodes
if (!result?.data?.Id) {
throw new Error('Server not found');
}
settings.jellyfin.serverId = result.Id;
settings.jellyfin.name = result.ServerName;
settings.save();
} catch (e) {
logger.error('Something went wrong testing Jellyfin connection', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to connect to Jellyfin.',
});
}
return res.status(200).json(settings.jellyfin);
});
@@ -272,7 +311,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
order: { id: 'ASC' },
});
const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
getHostname(),
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);
@@ -288,10 +327,13 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
// Automatic Library grouping is not supported when user views are used to get library
if (account.Configuration.GroupedFolders.length > 0) {
return next({ status: 501, message: 'SYNC_ERROR_GROUPED_FOLDERS' });
return next({
status: 501,
message: ApiErrorCode.SyncErrorGroupedFolders,
});
}
return next({ status: 404, message: 'SYNC_ERROR_NO_LIBRARIES' });
return next({ status: 404, message: ApiErrorCode.SyncErrorNoLibraries });
}
const newLibraries: Library[] = libraries.map((library) => {
@@ -322,16 +364,13 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
});
settingsRoutes.get('/jellyfin/users', async (req, res) => {
const settings = getSettings();
const { hostname, externalHostname } = getSettings().jellyfin;
let jellyfinHost =
const { ip, port, useSsl, urlBase, externalHostname } =
getSettings().jellyfin;
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
: `${useSsl ? 'https' : 'http'}://${ip}:${port}${urlBase}`;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
@@ -339,7 +378,6 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
order: { id: 'ASC' },
});
const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);

View File

@@ -496,7 +496,6 @@ router.post(
order: { id: 'ASC' },
});
const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);
@@ -504,15 +503,15 @@ router.post(
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = [];
const { hostname, externalHostname } = getSettings().jellyfin;
let jellyfinHost =
const { ip, port, urlBase, useSsl, externalHostname } =
getSettings().jellyfin;
const hostname = `${useSsl ? 'https' : 'http'}://${ip}:${port}${urlBase}`;
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const jellyfinUsers = await jellyfinClient.getUsers();

View File

@@ -0,0 +1,18 @@
import { getSettings } from '@server/lib/settings';
interface HostnameParams {
useSsl?: boolean;
ip?: string;
port?: number;
urlBase?: string;
}
export const getHostname = (params?: HostnameParams): string => {
const settings = params ? params : getSettings().jellyfin;
const { useSsl, ip, port, urlBase } = settings;
const hostname = `${useSsl ? 'https' : 'http'}://${ip}:${port}${urlBase}`;
return hostname;
};