mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
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:
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ?? ''
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
18
server/utils/getHostname.ts
Normal file
18
server/utils/getHostname.ts
Normal 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;
|
||||
};
|
||||
@@ -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<JellyfinLoginProps> = ({
|
||||
|
||||
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<JellyfinLoginProps> = ({
|
||||
mediaServerName:
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
username: '',
|
||||
password: '',
|
||||
host: '',
|
||||
hostname: '',
|
||||
port: 8096,
|
||||
useSsl: false,
|
||||
urlBase: '',
|
||||
email: '',
|
||||
}}
|
||||
validationSchema={LoginSchema}
|
||||
@@ -89,10 +119,14 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
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<JellyfinLoginProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, isValid }) => (
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
values,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
}) => (
|
||||
<Form>
|
||||
<div className="sm:border-t sm:border-gray-800">
|
||||
<label htmlFor="host" className="text-label">
|
||||
{/* <label htmlFor="host" className="text-label">
|
||||
{intl.formatMessage(messages.host, mediaServerFormatValues)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
</label> */}
|
||||
{/* <div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="host"
|
||||
@@ -142,11 +183,91 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
{errors.host && touched.host && (
|
||||
<div className="error">{errors.host}</div>
|
||||
)}
|
||||
</div> */}
|
||||
<div className="flex flex-col sm:flex-row sm:gap-4">
|
||||
<div className="w-full">
|
||||
<label htmlFor="hostname" className="text-label">
|
||||
{intl.formatMessage(
|
||||
messages.hostname,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mb-0 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||
{values.useSsl ? 'https://' : 'http://'}
|
||||
</span>
|
||||
<Field
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
type="text"
|
||||
className="rounded-r-only flex-1"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.hostname,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{errors.hostname && touched.hostname && (
|
||||
<div className="error">{errors.hostname}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="port" className="text-label">
|
||||
{intl.formatMessage(messages.port)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0">
|
||||
<Field
|
||||
id="port"
|
||||
name="port"
|
||||
inputMode="numeric"
|
||||
type="text"
|
||||
className="short flex-1"
|
||||
placeholder={intl.formatMessage(messages.port)}
|
||||
/>
|
||||
{errors.port && touched.port && (
|
||||
<div className="error">{errors.port}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label htmlFor="useSsl" className="text-label mt-2">
|
||||
{intl.formatMessage(messages.enablessl)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="useSsl"
|
||||
name="useSsl"
|
||||
type="checkbox"
|
||||
onChange={() => {
|
||||
setFieldValue('useSsl', !values.useSsl);
|
||||
setFieldValue('port', values.useSsl ? 8096 : 443);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label htmlFor="urlBase" className="text-label mt-1">
|
||||
{intl.formatMessage(messages.urlBase)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
type="text"
|
||||
inputMode="url"
|
||||
id="urlBase"
|
||||
name="urlBase"
|
||||
placeholder={intl.formatMessage(messages.urlBase)}
|
||||
/>
|
||||
</div>
|
||||
{errors.urlBase && touched.urlBase && (
|
||||
<div className="error">{errors.urlBase}</div>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="text-label"
|
||||
style={{ display: 'inline-flex' }}
|
||||
className="text-label inline-flex gap-1 align-middle"
|
||||
>
|
||||
{intl.formatMessage(messages.email)}
|
||||
<span className="label-tip">
|
||||
@@ -162,7 +283,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
</Tooltip>
|
||||
</span>
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="mt-1 sm:col-span-2 sm:mb-2 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="email"
|
||||
|
||||
@@ -32,14 +32,18 @@ const messages = defineMessages({
|
||||
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.',
|
||||
externalUrl: 'External URL',
|
||||
internalUrl: 'Internal URL',
|
||||
// internalUrl: 'Internal URL',
|
||||
hostname: 'Hostname or IP Address',
|
||||
port: 'Port',
|
||||
enablessl: 'Use SSL',
|
||||
urlBase: 'URL Base',
|
||||
jellyfinForgotPasswordUrl: 'Forgot Password URL',
|
||||
jellyfinSyncFailedNoLibrariesFound: 'No libraries were found',
|
||||
jellyfinSyncFailedAutomaticGroupedFolders:
|
||||
'Custom authentication with Automatic Library Grouping not supported',
|
||||
jellyfinSyncFailedGenericError:
|
||||
'Something went wrong while syncing libraries',
|
||||
validationUrl: 'You must provide a valid URL',
|
||||
|
||||
syncing: 'Syncing',
|
||||
syncJellyfin: 'Sync Libraries',
|
||||
manualscanJellyfin: 'Manual Library Scan',
|
||||
@@ -50,6 +54,12 @@ const messages = defineMessages({
|
||||
librariesRemaining: 'Libraries Remaining: {count}',
|
||||
startscan: 'Start Scan',
|
||||
cancelscan: 'Cancel Scan',
|
||||
validationUrl: 'You must provide a valid URL',
|
||||
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',
|
||||
});
|
||||
|
||||
interface Library {
|
||||
@@ -65,6 +75,7 @@ interface SyncStatus {
|
||||
currentLibrary?: Library;
|
||||
libraries: Library[];
|
||||
}
|
||||
|
||||
interface SettingsJellyfinProps {
|
||||
showAdvancedSettings?: boolean;
|
||||
onComplete?: () => void;
|
||||
@@ -93,18 +104,59 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const JellyfinSettingsSchema = Yup.object().shape({
|
||||
jellyfinExternalUrl: Yup.string().matches(
|
||||
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
|
||||
intl.formatMessage(messages.validationUrl)
|
||||
),
|
||||
jellyfinInternalUrl: Yup.string().matches(
|
||||
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
|
||||
intl.formatMessage(messages.validationUrl)
|
||||
),
|
||||
jellyfinForgotPasswordUrl: Yup.string().matches(
|
||||
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
|
||||
intl.formatMessage(messages.validationUrl)
|
||||
),
|
||||
hostname: Yup.string()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationHostnameRequired))
|
||||
.matches(
|
||||
/^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
|
||||
intl.formatMessage(messages.validationHostnameRequired)
|
||||
),
|
||||
port: Yup.number().when(['hostname'], {
|
||||
is: (value: unknown) => !!value,
|
||||
then: Yup.number()
|
||||
.typeError(intl.formatMessage(messages.validationPortRequired))
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationPortRequired)),
|
||||
otherwise: Yup.number()
|
||||
.typeError(intl.formatMessage(messages.validationPortRequired))
|
||||
.nullable(),
|
||||
}),
|
||||
urlBase: Yup.string()
|
||||
.test(
|
||||
'leading-slash',
|
||||
intl.formatMessage(messages.validationUrlBaseLeadingSlash),
|
||||
(value) => !value || value.startsWith('/')
|
||||
)
|
||||
.test(
|
||||
'trailing-slash',
|
||||
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
|
||||
(value) => !value || !value.endsWith('/')
|
||||
),
|
||||
jellyfinExternalUrl: Yup.string()
|
||||
.nullable()
|
||||
.url(intl.formatMessage(messages.validationUrl))
|
||||
.test(
|
||||
'no-trailing-slash',
|
||||
intl.formatMessage(messages.validationUrlTrailingSlash),
|
||||
(value) => !value || !value.endsWith('/')
|
||||
),
|
||||
jellyfinForgotPasswordUrl: Yup.string()
|
||||
.nullable()
|
||||
.url(intl.formatMessage(messages.validationUrl))
|
||||
.test(
|
||||
'no-trailing-slash',
|
||||
intl.formatMessage(messages.validationUrlTrailingSlash),
|
||||
(value) => !value || !value.endsWith('/')
|
||||
),
|
||||
|
||||
// jellyfinInternalUrl: Yup.string().matches(
|
||||
// /^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
|
||||
// intl.formatMessage(messages.validationUrl)
|
||||
// ),
|
||||
// jellyfinForgotPasswordUrl: Yup.string().matches(
|
||||
// /^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
|
||||
// intl.formatMessage(messages.validationUrl)
|
||||
// ),
|
||||
});
|
||||
|
||||
const activeLibraries =
|
||||
@@ -394,7 +446,11 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
</div>
|
||||
<Formik
|
||||
initialValues={{
|
||||
jellyfinInternalUrl: data?.hostname || '',
|
||||
// jellyfinInternalUrl: data?.hostname || '',
|
||||
hostname: data?.ip,
|
||||
port: data?.port ?? 8096,
|
||||
useSsl: data?.useSsl,
|
||||
urlBase: data?.urlBase || '',
|
||||
jellyfinExternalUrl: data?.externalHostname || '',
|
||||
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
|
||||
}}
|
||||
@@ -402,7 +458,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/jellyfin', {
|
||||
hostname: values.jellyfinInternalUrl,
|
||||
ip: values.hostname,
|
||||
port: Number(values.port),
|
||||
useSsl: values.useSsl,
|
||||
urlBase: values.urlBase,
|
||||
externalHostname: values.jellyfinExternalUrl,
|
||||
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
|
||||
} as JellyfinSettings);
|
||||
@@ -437,10 +496,18 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, handleSubmit, isSubmitting, isValid }) => {
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
values,
|
||||
setFieldValue,
|
||||
handleSubmit,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
}) => {
|
||||
return (
|
||||
<form className="section" onSubmit={handleSubmit}>
|
||||
<div className="form-row">
|
||||
{/* <div className="form-row">
|
||||
<label htmlFor="jellyfinInternalUrl" className="text-label">
|
||||
{intl.formatMessage(messages.internalUrl)}
|
||||
</label>
|
||||
@@ -459,6 +526,87 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
{errors.jellyfinInternalUrl}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div> */}
|
||||
<div className="form-row">
|
||||
<label htmlFor="hostname" className="text-label">
|
||||
{intl.formatMessage(messages.hostname)}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||
{values.useSsl ? 'https://' : 'http://'}
|
||||
</span>
|
||||
<Field
|
||||
type="text"
|
||||
inputMode="url"
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
className="rounded-r-only"
|
||||
/>
|
||||
</div>
|
||||
{errors.hostname &&
|
||||
touched.hostname &&
|
||||
typeof errors.hostname === 'string' && (
|
||||
<div className="error">{errors.hostname}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="port" className="text-label">
|
||||
{intl.formatMessage(messages.port)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
id="port"
|
||||
name="port"
|
||||
className="short"
|
||||
/>
|
||||
{errors.port &&
|
||||
touched.port &&
|
||||
typeof errors.port === 'string' && (
|
||||
<div className="error">{errors.port}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="useSsl" className="checkbox-label">
|
||||
{intl.formatMessage(messages.enablessl)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="useSsl"
|
||||
name="useSsl"
|
||||
onChange={() => {
|
||||
setFieldValue('useSsl', !values.useSsl);
|
||||
setFieldValue('port', values.useSsl ? 8096 : 443);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="urlBase" className="text-label">
|
||||
{intl.formatMessage(messages.urlBase)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
type="text"
|
||||
inputMode="url"
|
||||
id="urlBase"
|
||||
name="urlBase"
|
||||
/>
|
||||
</div>
|
||||
{errors.urlBase &&
|
||||
touched.urlBase &&
|
||||
typeof errors.urlBase === 'string' && (
|
||||
<div className="error">{errors.urlBase}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
|
||||
Reference in New Issue
Block a user