diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index bab7ea72..163fbcdc 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -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 { + try { + // TODO: remove axios from here + const systemInfoResponse = await this.axios.get('/System/Info'); + + return systemInfoResponse; + } catch (e) { + //TODO: Use the api error codes + throw new Error('Invalid auth token'); + } + } + public async getServerName(): Promise { try { const account = await this.axios.get( @@ -220,6 +233,7 @@ class JellyfinAPI { public async getLibraries(): Promise { try { + console.log('getting libraries with url', this.jellyfinHost); const mediaFolders = await this.axios.get(`/Library/MediaFolders`); return this.mapLibraries(mediaFolders.data.Items); diff --git a/server/constants/error.ts b/server/constants/error.ts index 87e37e4c..2f06d52a 100644 --- a/server/constants/error.ts +++ b/server/constants/error.ts @@ -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', } diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 1932670e..ce8892c9 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -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}`; diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 8b37bc85..c541eeef 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -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 ); diff --git a/server/lib/scanners/jellyfin/index.ts b/server/lib/scanners/jellyfin/index.ts index f5b0f66a..57edec47 100644 --- a/server/lib/scanners/jellyfin/index.ts +++ b/server/lib/scanners/jellyfin/index.ts @@ -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 ); diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 63f95236..dda681df 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -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; } diff --git a/server/routes/auth.ts b/server/routes/auth.ts index c0f789a1..dca58244 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -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({ diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 41821dca..020f5a03 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -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 ?? '' ); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 789c9076..37931f08 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -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(); diff --git a/server/utils/getHostname.ts b/server/utils/getHostname.ts new file mode 100644 index 00000000..9fa110cd --- /dev/null +++ b/server/utils/getHostname.ts @@ -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; +}; diff --git a/src/components/Login/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx index 7403392e..0996bf0f 100644 --- a/src/components/Login/JellyfinLogin.tsx +++ b/src/components/Login/JellyfinLogin.tsx @@ -14,7 +14,10 @@ import * as Yup from 'yup'; const messages = defineMessages({ username: 'Username', password: 'Password', - host: '{mediaServerName} URL', + hostname: '{mediaServerName} URL', + port: 'Port', + enablessl: 'Use SSL', + urlBase: 'URL Base', email: 'Email', emailtooltip: 'Address does not need to be associated with your {mediaServerName} instance.', @@ -24,6 +27,11 @@ const messages = defineMessages({ validationemailformat: 'Valid email required', validationusernamerequired: 'Username required', validationpasswordrequired: 'Password required', + validationHostnameRequired: 'You must provide a valid hostname or IP address', + validationPortRequired: 'You must provide a valid port number', + validationUrlTrailingSlash: 'URL must not end in a trailing slash', + validationUrlBaseLeadingSlash: 'URL base must have a leading slash', + validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash', loginerror: 'Something went wrong while trying to sign in.', adminerror: 'You must use an admin account to sign in.', credentialerror: 'The username or password is incorrect.', @@ -51,16 +59,34 @@ const JellyfinLogin: React.FC = ({ if (initial) { const LoginSchema = Yup.object().shape({ - host: Yup.string() + // host: Yup.string() + // .matches( + // /^(?:(?:(?:https?):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/, + // intl.formatMessage(messages.validationhostformat) + // ) + // .required( + // intl.formatMessage(messages.validationhostrequired, { + // mediaServerName: + // publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin', + // }) + // ), + hostname: Yup.string().required( + intl.formatMessage(messages.validationhostrequired, { + mediaServerName: + publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin', + }) + ), + port: Yup.number().required( + intl.formatMessage(messages.validationPortRequired) + ), + urlBase: Yup.string() .matches( - /^(?:(?:(?:https?):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/, - intl.formatMessage(messages.validationhostformat) + /^(\/[^/].*[^/]$)/, + intl.formatMessage(messages.validationUrlBaseLeadingSlash) ) - .required( - intl.formatMessage(messages.validationhostrequired, { - mediaServerName: - publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin', - }) + .matches( + /^(.*[^/])$/, + intl.formatMessage(messages.validationUrlBaseTrailingSlash) ), email: Yup.string() .email(intl.formatMessage(messages.validationemailformat)) @@ -75,12 +101,16 @@ const JellyfinLogin: React.FC = ({ mediaServerName: publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin', }; + return ( = ({ await axios.post('/api/v1/auth/jellyfin', { username: values.username, password: values.password, - hostname: values.host, + hostname: values.hostname, + port: values.port, + useSsl: values.useSsl, + urlBase: values.urlBase, email: values.email, }); } catch (e) { + console.log(e); let errorMessage = null; switch (e.response?.data?.message) { case ApiErrorCode.InvalidUrl: @@ -121,13 +155,20 @@ const JellyfinLogin: React.FC = ({ } }} > - {({ errors, touched, isSubmitting, isValid }) => ( + {({ + errors, + touched, + values, + setFieldValue, + isSubmitting, + isValid, + }) => (
-