Compare commits

...

23 Commits

Author SHA1 Message Date
fallenbagel
b72a4a5ad8 refactor(settings): better erorr logging when jellyfin connection test fails in settings page 2024-06-13 17:08:11 +05:00
fallenbagel
6a51ade676 Merge remote-tracking branch 'origin/develop' into refactor-jellyfin-settings 2024-06-13 17:03:01 +05:00
fallenbagel
310d789bfb refactor: revert the changes brought to try and fix formatter
added back the rest of the cypress settings and removed cypress settings from .prettierignore
2024-06-01 06:17:40 +05:00
fallenbagel
f3641d4cab Merge remote-tracking branch 'origin/develop' into refactor-jellyfin-settings 2024-06-01 06:10:55 +05:00
fallenbagel
2a1aab5d24 ci: attempt at trying to get formatter to pass on cypress config json file 2024-06-01 06:02:46 +05:00
fallenbagel
6ea76ecf31 Merge remote-tracking branch 'origin/develop' into refactor-jellyfin-settings 2024-06-01 05:53:06 +05:00
fallenbagel
62f97b385f test(cypress): add only missing jobs to the cypress settings 2024-06-01 05:45:45 +05:00
fallenbagel
6613254d43 chore(prettier): ran formatter on cypress config to fix format check error
format check locally passes on this file. However, it fails during the github actions format check.
Therefore, json language features formatter was run instead of prettier to see if that fixes the
issue.
2024-06-01 05:29:15 +05:00
fallenbagel
8c4f37836f chore(prettierignore): ignore cypress/config/settings.cypress.json as it does not need prettier 2024-06-01 05:24:54 +05:00
fallenbagel
2700694a99 test(cypress): fix cypress tests failing
cypress settings were lacking some of the jobs so when the startJobs() is called when the app
starts, it was failing to schedule the jobs where their cron timings were not specified in the
cypress settings. Therefore, this commit adds those jobs back. In addition, other setting options
were added to keep cypress settings consistent with a normal user.
2024-06-01 05:08:24 +05:00
fallenbagel
ce04315899 refactor(settingsmigrator): settings migration handler and the migrations 2024-06-01 03:31:50 +05:00
fallenbagel
130bb298f7 refactor(settings): use migrator for dynamic settings migrations 2024-06-01 02:31:16 +05:00
fallenbagel
226d451adf fix(settings): merging the wrong settings source 2024-05-31 21:20:12 +05:00
fallenbagel
8384d411ba fix(settings): jellyfin hostname should be carried out if hostname exists 2024-05-31 21:12:02 +05:00
fallenbagel
e640ff6c2e fix(settings): jellyfin migrations replacing the rest of the settings 2024-05-31 20:48:59 +05:00
Fallenbagel
04b86c30e6 Merge remote-tracking branch 'origin/develop' into refactor-jellyfin-settings 2024-05-30 20:31:19 +05:00
fallenbagel
fe8c781cba fix(auth): auth failing with jellyfin login is disabled 2024-05-28 19:51:48 +05:00
fallenbagel
43e0a29543 refactor(i18n): extract translation keys 2024-05-26 19:42:14 +05:00
fallenbagel
57336d8a75 refactor: clean up commented out code 2024-05-26 19:38:39 +05:00
fallenbagel
822a0768cf fix: store req.body jellyfin settings temporarily and store only if valid
This should fix the issue where settings are saved even if the url
was invalid. Now the settings will only be saved if the url is
valid. Sort of like a test connection.
2024-05-26 19:24:26 +05:00
fallenbagel
e34881064a refactor: remove console logs and use getHostname and ApiErrorCodes 2024-05-26 19:24:01 +05:00
fallenbagel
135155cd85 Merge remote-tracking branch 'origin/develop' into refactor-jellyfin-settings 2024-05-26 18:30:24 +05:00
fallenbagel
68952a9239 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.
2024-05-25 05:44:05 +05:00
16 changed files with 529 additions and 118 deletions

View File

@@ -19,6 +19,7 @@
"region": "", "region": "",
"originalLanguage": "", "originalLanguage": "",
"trustProxy": false, "trustProxy": false,
"mediaServerType": 1,
"partialRequestsEnabled": true, "partialRequestsEnabled": true,
"locale": "en" "locale": "en"
}, },
@@ -37,6 +38,17 @@
], ],
"machineId": "test" "machineId": "test"
}, },
"jellyfin": {
"name": "",
"ip": "",
"port": 8096,
"useSsl": false,
"urlBase": "",
"externalHostname": "",
"jellyfinForgotPasswordUrl": "",
"libraries": [],
"serverId": ""
},
"tautulli": {}, "tautulli": {},
"radarr": [], "radarr": [],
"sonarr": [], "sonarr": [],
@@ -139,11 +151,26 @@
"sonarr-scan": { "sonarr-scan": {
"schedule": "0 30 4 * * *" "schedule": "0 30 4 * * *"
}, },
"plex-watchlist-sync": {
"schedule": "0 */10 * * * *"
},
"availability-sync": {
"schedule": "0 0 5 * * *"
},
"download-sync": { "download-sync": {
"schedule": "0 * * * * *" "schedule": "0 * * * * *"
}, },
"download-sync-reset": { "download-sync-reset": {
"schedule": "0 0 1 * * *" "schedule": "0 0 1 * * *"
},
"jellyfin-recently-added-scan": {
"schedule": "0 */5 * * * *"
},
"jellyfin-full-scan": {
"schedule": "0 0 3 * * *"
},
"image-cache-cleanup": {
"schedule": "0 0 5 * * *"
} }
} }
} }

View File

@@ -184,6 +184,16 @@ class JellyfinAPI extends ExternalAPI {
return; return;
} }
public async getSystemInfo(): Promise<any> {
try {
const systemInfoResponse = await this.get<any>('/System/Info');
return systemInfoResponse;
} catch (e) {
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async getServerName(): Promise<string> { public async getServerName(): Promise<string> {
try { try {
const serverResponse = await this.get<JellyfinUserResponse>( const serverResponse = await this.get<JellyfinUserResponse>(

View File

@@ -3,5 +3,7 @@ export enum ApiErrorCode {
InvalidCredentials = 'INVALID_CREDENTIALS', InvalidCredentials = 'INVALID_CREDENTIALS',
InvalidAuthToken = 'INVALID_AUTH_TOKEN', InvalidAuthToken = 'INVALID_AUTH_TOKEN',
NotAdmin = 'NOT_ADMIN', NotAdmin = 'NOT_ADMIN',
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
Unknown = 'UNKNOWN', Unknown = 'UNKNOWN',
} }

View File

@@ -9,6 +9,7 @@ import type { DownloadingItem } from '@server/lib/downloadtracker';
import downloadTracker from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { getHostname } from '@server/utils/getHostname';
import { import {
AfterLoad, AfterLoad,
Column, Column,
@@ -211,15 +212,12 @@ class Media {
} else { } else {
const pageName = const pageName =
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details'; process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
const { serverId, hostname, externalHostname } = getSettings().jellyfin; const { serverId, externalHostname } = getSettings().jellyfin;
let jellyfinHost =
const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
: hostname; : getHostname();
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
if (this.jellyfinMediaId) { if (this.jellyfinMediaId) {
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`; 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 type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { getHostname } from '@server/utils/getHostname';
class AvailabilitySync { class AvailabilitySync {
public running = false; public running = false;
@@ -84,7 +85,7 @@ class AvailabilitySync {
) { ) {
if (admin) { if (admin) {
this.jellyfinClient = new JellyfinAPI( this.jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '', getHostname(),
admin.jellyfinAuthToken, admin.jellyfinAuthToken,
admin.jellyfinDeviceId admin.jellyfinDeviceId
); );

View File

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

View File

@@ -1,10 +1,11 @@
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import { Permission } from '@server/lib/permissions';
import { runMigrations } from '@server/lib/settings/migrator';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import fs from 'fs'; import fs from 'fs';
import { merge } from 'lodash'; import { merge } from 'lodash';
import path from 'path'; import path from 'path';
import webpush from 'web-push'; import webpush from 'web-push';
import { Permission } from './permissions';
export interface Library { export interface Library {
id: string; id: string;
@@ -38,7 +39,10 @@ export interface PlexSettings {
export interface JellyfinSettings { export interface JellyfinSettings {
name: string; name: string;
hostname: string; ip: string;
port: number;
useSsl?: boolean;
urlBase?: string;
externalHostname?: string; externalHostname?: string;
jellyfinForgotPasswordUrl?: string; jellyfinForgotPasswordUrl?: string;
libraries: Library[]; libraries: Library[];
@@ -130,7 +134,6 @@ interface FullPublicSettings extends PublicSettings {
region: string; region: string;
originalLanguage: string; originalLanguage: string;
mediaServerType: number; mediaServerType: number;
jellyfinHost?: string;
jellyfinExternalHost?: string; jellyfinExternalHost?: string;
jellyfinForgotPasswordUrl?: string; jellyfinForgotPasswordUrl?: string;
jellyfinServerName?: string; jellyfinServerName?: string;
@@ -274,7 +277,7 @@ export type JobId =
| 'image-cache-cleanup' | 'image-cache-cleanup'
| 'availability-sync'; | 'availability-sync';
interface AllSettings { export interface AllSettings {
clientId: string; clientId: string;
vapidPublic: string; vapidPublic: string;
vapidPrivate: string; vapidPrivate: string;
@@ -291,7 +294,7 @@ interface AllSettings {
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/settings.json` ? `${process.env.CONFIG_DIRECTORY}/settings.json`
: path.join(__dirname, '../../config/settings.json'); : path.join(__dirname, '../../../config/settings.json');
class Settings { class Settings {
private data: AllSettings; private data: AllSettings;
@@ -331,7 +334,10 @@ class Settings {
}, },
jellyfin: { jellyfin: {
name: '', name: '',
hostname: '', ip: '',
port: 8096,
useSsl: false,
urlBase: '',
externalHostname: '', externalHostname: '',
jellyfinForgotPasswordUrl: '', jellyfinForgotPasswordUrl: '',
libraries: [], libraries: [],
@@ -547,8 +553,6 @@ class Settings {
region: this.data.main.region, region: this.data.main.region,
originalLanguage: this.data.main.originalLanguage, originalLanguage: this.data.main.originalLanguage,
mediaServerType: this.main.mediaServerType, mediaServerType: this.main.mediaServerType,
jellyfinHost: this.jellyfin.hostname,
jellyfinExternalHost: this.jellyfin.externalHostname,
partialRequestsEnabled: this.data.main.partialRequestsEnabled, partialRequestsEnabled: this.data.main.partialRequestsEnabled,
cacheImages: this.data.main.cacheImages, cacheImages: this.data.main.cacheImages,
vapidPublic: this.vapidPublic, vapidPublic: this.vapidPublic,
@@ -637,7 +641,11 @@ class Settings {
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8'); const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
if (data) { if (data) {
this.data = merge(this.data, JSON.parse(data)); const parsedJson = JSON.parse(data);
this.data = runMigrations(parsedJson);
this.data = merge(this.data, parsedJson);
this.save(); this.save();
} }
return this; return this;

View File

@@ -0,0 +1,30 @@
import type { AllSettings } from '@server/lib/settings';
const migrateHostname = (settings: any): AllSettings => {
const oldJellyfinSettings = settings.jellyfin;
if (oldJellyfinSettings && oldJellyfinSettings.hostname) {
const { hostname } = oldJellyfinSettings;
const protocolMatch = hostname.match(/^(https?):\/\//i);
const useSsl = protocolMatch && protocolMatch[1].toLowerCase() === 'https';
const remainingUrl = hostname.replace(/^(https?):\/\//i, '');
const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/);
delete oldJellyfinSettings.hostname;
if (urlMatch) {
const [, ip, , port, urlBase] = urlMatch;
settings.jellyfin = {
...settings.jellyfin,
ip,
port: port || (useSsl ? 443 : 80),
useSsl,
urlBase: urlBase ? urlBase.replace(/\/$/, '') : '',
};
}
}
if (settings.jellyfin && settings.jellyfin.hostname) {
delete settings.jellyfin.hostname;
}
return settings;
};
export default migrateHostname;

View File

@@ -0,0 +1,21 @@
import type { AllSettings } from '@server/lib/settings';
import fs from 'fs';
import path from 'path';
const migrationsDir = path.join(__dirname, 'migrations');
export const runMigrations = (settings: AllSettings): AllSettings => {
const migrations = fs
.readdirSync(migrationsDir)
.filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
// eslint-disable-next-line @typescript-eslint/no-var-requires
.map((file) => require(path.join(migrationsDir, file)).default);
let migrated = settings;
for (const migration of migrations) {
migrated = migration(migrated);
}
return migrated;
};

View File

@@ -11,6 +11,7 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import { ApiError } from '@server/types/error'; import { ApiError } from '@server/types/error';
import { getHostname } from '@server/utils/getHostname';
import * as EmailValidator from 'email-validator'; import * as EmailValidator from 'email-validator';
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url'; import gravatarUrl from 'gravatar-url';
@@ -222,30 +223,39 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
username?: string; username?: string;
password?: string; password?: string;
hostname?: string; hostname?: string;
port?: number;
urlBase?: string;
useSsl?: boolean;
email?: string; email?: string;
}; };
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured //Make sure jellyfin login is enabled, but only if jellyfin is not already configured
if ( if (
settings.main.mediaServerType !== MediaServerType.JELLYFIN && settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
settings.jellyfin.hostname !== '' settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
) { ) {
return res.status(500).json({ error: 'Jellyfin login is disabled' }); return res.status(500).json({ error: 'Jellyfin login is disabled' });
} else if (!body.username) { } else if (!body.username) {
return res.status(500).json({ error: 'You must provide an 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 return res
.status(500) .status(500)
.json({ error: 'Jellyfin hostname already configured' }); .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.' }); return res.status(500).json({ error: 'No hostname provided.' });
} }
try { try {
const hostname = const hostname =
settings.jellyfin.hostname !== '' settings.jellyfin.ip !== ''
? settings.jellyfin.hostname ? getHostname()
: body.hostname ?? ''; : getHostname({
useSsl: body.useSsl,
ip: body.hostname,
port: body.port,
urlBase: body.urlBase,
});
const { externalHostname } = getSettings().jellyfin; const { externalHostname } = getSettings().jellyfin;
// Try to find deviceId that corresponds to jellyfin user, else generate a new one // Try to find deviceId that corresponds to jellyfin user, else generate a new one
@@ -261,17 +271,14 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
'base64' 'base64'
); );
} }
// First we need to attempt to log the user in to jellyfin // First we need to attempt to log the user in to jellyfin
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId); const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
let jellyfinHost = const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
: hostname; : hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
const ip = req.ip; const ip = req.ip;
let clientIp; let clientIp;
@@ -328,8 +335,11 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
const serverName = await jellyfinserver.getServerName(); const serverName = await jellyfinserver.getServerName();
settings.jellyfin.name = serverName; settings.jellyfin.name = serverName;
settings.jellyfin.hostname = body.hostname ?? '';
settings.jellyfin.serverId = account.User.ServerId; settings.jellyfin.serverId = account.User.ServerId;
settings.jellyfin.ip = body.hostname ?? '';
settings.jellyfin.port = body.port ?? 8096;
settings.jellyfin.urlBase = body.urlBase ?? '';
settings.jellyfin.useSsl = body.useSsl ?? false;
settings.save(); settings.save();
startJobs(); startJobs();
@@ -444,7 +454,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
label: 'Auth', label: 'Auth',
error: e.errorCode, error: e.errorCode,
status: e.statusCode, status: e.statusCode,
hostname: body.hostname, hostname: getHostname({
useSsl: body.useSsl,
ip: body.hostname,
port: body.port,
urlBase: body.urlBase,
}),
} }
); );
return next({ return next({

View File

@@ -2,6 +2,7 @@ import JellyfinAPI from '@server/api/jellyfin';
import PlexAPI from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi';
import PlexTvAPI from '@server/api/plextv'; import PlexTvAPI from '@server/api/plextv';
import TautulliAPI from '@server/api/tautulli'; import TautulliAPI from '@server/api/tautulli';
import { ApiErrorCode } from '@server/constants/error';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest'; import { MediaRequest } from '@server/entity/MediaRequest';
@@ -24,8 +25,10 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import discoverSettingRoutes from '@server/routes/settings/discover'; import discoverSettingRoutes from '@server/routes/settings/discover';
import { ApiError } from '@server/types/error';
import { appDataPath } from '@server/utils/appDataVolume'; import { appDataPath } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion'; import { getAppVersion } from '@server/utils/appVersion';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express'; import { Router } from 'express';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import fs from 'fs'; import fs from 'fs';
@@ -252,11 +255,59 @@ settingsRoutes.get('/jellyfin', (_req, res) => {
res.status(200).json(settings.jellyfin); 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(); const settings = getSettings();
settings.jellyfin = merge(settings.jellyfin, req.body); try {
settings.save(); const admin = await userRepository.findOneOrFail({
where: { id: 1 },
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' },
});
const tempJellyfinSettings = { ...settings.jellyfin, ...req.body };
const jellyfinClient = new JellyfinAPI(
getHostname(tempJellyfinSettings),
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);
const result = await jellyfinClient.getSystemInfo();
if (!result?.Id) {
throw new ApiError(result?.status, ApiErrorCode.InvalidUrl);
}
Object.assign(settings.jellyfin, req.body);
settings.jellyfin.serverId = result.Id;
settings.jellyfin.name = result.ServerName;
settings.save();
} catch (e) {
if (e instanceof ApiError) {
logger.error('Something went wrong testing Jellyfin connection', {
label: 'API',
status: e.statusCode,
errorMessage: ApiErrorCode.InvalidUrl,
});
return next({
status: e.statusCode,
message: ApiErrorCode.InvalidUrl,
});
} else {
logger.error('Something went wrong', {
label: 'API',
errorMessage: e.message,
});
return next({
status: e.statusCode ?? 500,
message: ApiErrorCode.Unknown,
});
}
}
return res.status(200).json(settings.jellyfin); return res.status(200).json(settings.jellyfin);
}); });
@@ -272,7 +323,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '', getHostname(),
admin.jellyfinAuthToken ?? '', admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -288,10 +339,13 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
// Automatic Library grouping is not supported when user views are used to get library // Automatic Library grouping is not supported when user views are used to get library
if (account.Configuration.GroupedFolders.length > 0) { 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) => { const newLibraries: Library[] = libraries.map((library) => {
@@ -322,16 +376,12 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
}); });
settingsRoutes.get('/jellyfin/users', async (req, res) => { settingsRoutes.get('/jellyfin/users', async (req, res) => {
const settings = getSettings(); const { externalHostname } = getSettings().jellyfin;
const { hostname, externalHostname } = getSettings().jellyfin; const jellyfinHost =
let jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
: hostname; : getHostname();
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'], select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
@@ -339,7 +389,6 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken ?? '', admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );

View File

@@ -20,6 +20,7 @@ import { hasPermission, Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url'; import gravatarUrl from 'gravatar-url';
import { findIndex, sortBy } from 'lodash'; import { findIndex, sortBy } from 'lodash';
@@ -496,7 +497,6 @@ router.post(
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken ?? '', admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -504,15 +504,14 @@ router.post(
//const jellyfinUsersResponse = await jellyfinClient.getUsers(); //const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = []; const createdUsers: User[] = [];
const { hostname, externalHostname } = getSettings().jellyfin; const { externalHostname } = getSettings().jellyfin;
let jellyfinHost = const hostname = getHostname();
const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
: hostname; : hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const jellyfinUsers = await jellyfinClient.getUsers(); 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;
};

View File

@@ -14,7 +14,10 @@ import * as Yup from 'yup';
const messages = defineMessages({ const messages = defineMessages({
username: 'Username', username: 'Username',
password: 'Password', password: 'Password',
host: '{mediaServerName} URL', hostname: '{mediaServerName} URL',
port: 'Port',
enablessl: 'Use SSL',
urlBase: 'URL Base',
email: 'Email', email: 'Email',
emailtooltip: emailtooltip:
'Address does not need to be associated with your {mediaServerName} instance.', 'Address does not need to be associated with your {mediaServerName} instance.',
@@ -24,6 +27,11 @@ const messages = defineMessages({
validationemailformat: 'Valid email required', validationemailformat: 'Valid email required',
validationusernamerequired: 'Username required', validationusernamerequired: 'Username required',
validationpasswordrequired: 'Password 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.', loginerror: 'Something went wrong while trying to sign in.',
adminerror: 'You must use an admin account to sign in.', adminerror: 'You must use an admin account to sign in.',
credentialerror: 'The username or password is incorrect.', credentialerror: 'The username or password is incorrect.',
@@ -51,16 +59,23 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
if (initial) { if (initial) {
const LoginSchema = Yup.object().shape({ const LoginSchema = Yup.object().shape({
host: Yup.string() 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( .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( .matches(
intl.formatMessage(messages.validationhostrequired, { /^(.*[^/])$/,
mediaServerName: intl.formatMessage(messages.validationUrlBaseTrailingSlash)
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
})
), ),
email: Yup.string() email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat)) .email(intl.formatMessage(messages.validationemailformat))
@@ -75,12 +90,16 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
mediaServerName: mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin', publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
}; };
return ( return (
<Formik <Formik
initialValues={{ initialValues={{
username: '', username: '',
password: '', password: '',
host: '', hostname: '',
port: 8096,
useSsl: false,
urlBase: '',
email: '', email: '',
}} }}
validationSchema={LoginSchema} validationSchema={LoginSchema}
@@ -89,7 +108,10 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
await axios.post('/api/v1/auth/jellyfin', { await axios.post('/api/v1/auth/jellyfin', {
username: values.username, username: values.username,
password: values.password, password: values.password,
hostname: values.host, hostname: values.hostname,
port: values.port,
useSsl: values.useSsl,
urlBase: values.urlBase,
email: values.email, email: values.email,
}); });
} catch (e) { } catch (e) {
@@ -121,32 +143,100 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
} }
}} }}
> >
{({ errors, touched, isSubmitting, isValid }) => ( {({
errors,
touched,
values,
setFieldValue,
isSubmitting,
isValid,
}) => (
<Form> <Form>
<div className="sm:border-t sm:border-gray-800"> <div className="sm:border-t sm:border-gray-800">
<label htmlFor="host" className="text-label"> <div className="flex flex-col sm:flex-row sm:gap-4">
{intl.formatMessage(messages.host, mediaServerFormatValues)} <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> </label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0"> <div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm"> <div className="flex rounded-md shadow-sm">
<Field <Field
id="host"
name="host"
type="text" type="text"
placeholder={intl.formatMessage( inputMode="url"
messages.host, id="urlBase"
mediaServerFormatValues name="urlBase"
)} placeholder={intl.formatMessage(messages.urlBase)}
/> />
</div> </div>
{errors.host && touched.host && ( {errors.urlBase && touched.urlBase && (
<div className="error">{errors.host}</div> <div className="error">{errors.urlBase}</div>
)} )}
</div> </div>
<label <label
htmlFor="email" htmlFor="email"
className="text-label" className="text-label inline-flex gap-1 align-middle"
style={{ display: 'inline-flex' }}
> >
{intl.formatMessage(messages.email)} {intl.formatMessage(messages.email)}
<span className="label-tip"> <span className="label-tip">
@@ -162,7 +252,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
</Tooltip> </Tooltip>
</span> </span>
</label> </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"> <div className="flex rounded-md shadow-sm">
<Field <Field
id="email" id="email"

View File

@@ -4,6 +4,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import LibraryItem from '@app/components/Settings/LibraryItem'; import LibraryItem from '@app/components/Settings/LibraryItem';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import { ApiErrorCode } from '@server/constants/error';
import type { JellyfinSettings } from '@server/lib/settings'; import type { JellyfinSettings } from '@server/lib/settings';
import axios from 'axios'; import axios from 'axios';
import { Field, Formik } from 'formik'; import { Field, Formik } from 'formik';
@@ -32,14 +33,17 @@ const messages = defineMessages({
jellyfinSettingsDescription: 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.',
externalUrl: 'External URL', externalUrl: 'External URL',
internalUrl: 'Internal URL', hostname: 'Hostname or IP Address',
port: 'Port',
enablessl: 'Use SSL',
urlBase: 'URL Base',
jellyfinForgotPasswordUrl: 'Forgot Password URL', jellyfinForgotPasswordUrl: 'Forgot Password URL',
jellyfinSyncFailedNoLibrariesFound: 'No libraries were found', jellyfinSyncFailedNoLibrariesFound: 'No libraries were found',
jellyfinSyncFailedAutomaticGroupedFolders: jellyfinSyncFailedAutomaticGroupedFolders:
'Custom authentication with Automatic Library Grouping not supported', 'Custom authentication with Automatic Library Grouping not supported',
jellyfinSyncFailedGenericError: jellyfinSyncFailedGenericError:
'Something went wrong while syncing libraries', 'Something went wrong while syncing libraries',
validationUrl: 'You must provide a valid URL', invalidurlerror: 'Unable to connect to {mediaServerName} server.',
syncing: 'Syncing', syncing: 'Syncing',
syncJellyfin: 'Sync Libraries', syncJellyfin: 'Sync Libraries',
manualscanJellyfin: 'Manual Library Scan', manualscanJellyfin: 'Manual Library Scan',
@@ -50,6 +54,12 @@ const messages = defineMessages({
librariesRemaining: 'Libraries Remaining: {count}', librariesRemaining: 'Libraries Remaining: {count}',
startscan: 'Start Scan', startscan: 'Start Scan',
cancelscan: 'Cancel 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 { interface Library {
@@ -65,6 +75,7 @@ interface SyncStatus {
currentLibrary?: Library; currentLibrary?: Library;
libraries: Library[]; libraries: Library[];
} }
interface SettingsJellyfinProps { interface SettingsJellyfinProps {
showAdvancedSettings?: boolean; showAdvancedSettings?: boolean;
onComplete?: () => void; onComplete?: () => void;
@@ -93,18 +104,50 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
const { publicRuntimeConfig } = getConfig(); const { publicRuntimeConfig } = getConfig();
const JellyfinSettingsSchema = Yup.object().shape({ const JellyfinSettingsSchema = Yup.object().shape({
jellyfinExternalUrl: Yup.string().matches( hostname: Yup.string()
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm, .nullable()
intl.formatMessage(messages.validationUrl) .required(intl.formatMessage(messages.validationHostnameRequired))
), .matches(
jellyfinInternalUrl: Yup.string().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,
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm, intl.formatMessage(messages.validationHostnameRequired)
intl.formatMessage(messages.validationUrl) ),
), port: Yup.number().when(['hostname'], {
jellyfinForgotPasswordUrl: Yup.string().matches( is: (value: unknown) => !!value,
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm, then: Yup.number()
intl.formatMessage(messages.validationUrl) .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('/')
),
}); });
const activeLibraries = const activeLibraries =
@@ -394,7 +437,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
</div> </div>
<Formik <Formik
initialValues={{ initialValues={{
jellyfinInternalUrl: data?.hostname || '', hostname: data?.ip,
port: data?.port ?? 8096,
useSsl: data?.useSsl,
urlBase: data?.urlBase || '',
jellyfinExternalUrl: data?.externalHostname || '', jellyfinExternalUrl: data?.externalHostname || '',
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '', jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
}} }}
@@ -402,7 +448,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
await axios.post('/api/v1/settings/jellyfin', { 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, externalHostname: values.jellyfinExternalUrl,
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl, jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
} as JellyfinSettings); } as JellyfinSettings);
@@ -420,44 +469,127 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
} }
); );
} catch (e) { } catch (e) {
addToast( if (e.response?.data?.message === ApiErrorCode.InvalidUrl) {
intl.formatMessage(messages.jellyfinSettingsFailure, { addToast(
mediaServerName: intl.formatMessage(messages.invalidurlerror, {
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' mediaServerName:
? 'Emby' publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
: 'Jellyfin', ? 'Emby'
}), : 'Jellyfin',
{ }),
autoDismiss: true, {
appearance: 'error', autoDismiss: true,
} appearance: 'error',
); }
);
} else {
addToast(
intl.formatMessage(messages.jellyfinSettingsFailure, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
appearance: 'error',
}
);
}
} finally { } finally {
revalidate(); revalidate();
} }
}} }}
> >
{({ errors, touched, handleSubmit, isSubmitting, isValid }) => { {({
errors,
touched,
values,
setFieldValue,
handleSubmit,
isSubmitting,
isValid,
}) => {
return ( return (
<form className="section" onSubmit={handleSubmit}> <form className="section" onSubmit={handleSubmit}>
<div className="form-row"> <div className="form-row">
<label htmlFor="jellyfinInternalUrl" className="text-label"> <label htmlFor="hostname" className="text-label">
{intl.formatMessage(messages.internalUrl)} {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> </label>
<div className="form-input-area"> <div className="form-input-area">
<div className="form-input-field"> <div className="form-input-field">
<Field <Field
type="text" type="text"
inputMode="url" inputMode="url"
id="jellyfinInternalUrl" id="urlBase"
name="jellyfinInternalUrl" name="urlBase"
/> />
</div> </div>
{errors.jellyfinInternalUrl && {errors.urlBase &&
touched.jellyfinInternalUrl && ( touched.urlBase &&
<div className="error"> typeof errors.urlBase === 'string' && (
{errors.jellyfinInternalUrl} <div className="error">{errors.urlBase}</div>
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -220,17 +220,19 @@
"components.Layout.VersionStatus.streamdevelop": "Jellyseerr Develop", "components.Layout.VersionStatus.streamdevelop": "Jellyseerr Develop",
"components.Layout.VersionStatus.streamstable": "Jellyseerr Stable", "components.Layout.VersionStatus.streamstable": "Jellyseerr Stable",
"components.Login.adminerror": "You must use an admin account to sign in.", "components.Login.adminerror": "You must use an admin account to sign in.",
"components.Login.invalidurlerror": "Unable to connect to {mediaServerName} server.",
"components.Login.credentialerror": "The username or password is incorrect.", "components.Login.credentialerror": "The username or password is incorrect.",
"components.Login.description": "Since this is your first time logging into {applicationName}, you are required to add a valid email address.", "components.Login.description": "Since this is your first time logging into {applicationName}, you are required to add a valid email address.",
"components.Login.email": "Email Address", "components.Login.email": "Email Address",
"components.Login.emailtooltip": "Address does not need to be associated with your {mediaServerName} instance.", "components.Login.emailtooltip": "Address does not need to be associated with your {mediaServerName} instance.",
"components.Login.enablessl": "Use SSL",
"components.Login.forgotpassword": "Forgot Password?", "components.Login.forgotpassword": "Forgot Password?",
"components.Login.host": "{mediaServerName} URL", "components.Login.hostname": "{mediaServerName} URL",
"components.Login.initialsignin": "Connect", "components.Login.initialsignin": "Connect",
"components.Login.initialsigningin": "Connecting…", "components.Login.initialsigningin": "Connecting…",
"components.Login.invalidurlerror": "Unable to connect to {mediaServerName} server.",
"components.Login.loginerror": "Something went wrong while trying to sign in.", "components.Login.loginerror": "Something went wrong while trying to sign in.",
"components.Login.password": "Password", "components.Login.password": "Password",
"components.Login.port": "Port",
"components.Login.save": "Add", "components.Login.save": "Add",
"components.Login.saving": "Adding…", "components.Login.saving": "Adding…",
"components.Login.signin": "Sign In", "components.Login.signin": "Sign In",
@@ -240,9 +242,15 @@
"components.Login.signinwithoverseerr": "Use your {applicationTitle} account", "components.Login.signinwithoverseerr": "Use your {applicationTitle} account",
"components.Login.signinwithplex": "Use your Plex account", "components.Login.signinwithplex": "Use your Plex account",
"components.Login.title": "Add Email", "components.Login.title": "Add Email",
"components.Login.urlBase": "URL Base",
"components.Login.username": "Username", "components.Login.username": "Username",
"components.Login.validationEmailFormat": "Invalid email", "components.Login.validationEmailFormat": "Invalid email",
"components.Login.validationEmailRequired": "You must provide an email", "components.Login.validationEmailRequired": "You must provide an email",
"components.Login.validationHostnameRequired": "You must provide a valid hostname or IP address",
"components.Login.validationPortRequired": "You must provide a valid port number",
"components.Login.validationUrlBaseLeadingSlash": "URL base must have a leading slash",
"components.Login.validationUrlBaseTrailingSlash": "URL base must not end in a trailing slash",
"components.Login.validationUrlTrailingSlash": "URL must not end in a trailing slash",
"components.Login.validationemailformat": "Valid email required", "components.Login.validationemailformat": "Valid email required",
"components.Login.validationemailrequired": "You must provide a valid email address", "components.Login.validationemailrequired": "You must provide a valid email address",
"components.Login.validationhostformat": "Valid URL required", "components.Login.validationhostformat": "Valid URL required",
@@ -937,7 +945,7 @@
"components.Settings.experimentalTooltip": "Enabling this setting may result in unexpected application behavior", "components.Settings.experimentalTooltip": "Enabling this setting may result in unexpected application behavior",
"components.Settings.externalUrl": "External URL", "components.Settings.externalUrl": "External URL",
"components.Settings.hostname": "Hostname or IP Address", "components.Settings.hostname": "Hostname or IP Address",
"components.Settings.internalUrl": "Internal URL", "components.Settings.invalidurlerror": "Unable to connect to {mediaServerName} server.",
"components.Settings.is4k": "4K", "components.Settings.is4k": "4K",
"components.Settings.jellyfinForgotPasswordUrl": "Forgot Password URL", "components.Settings.jellyfinForgotPasswordUrl": "Forgot Password URL",
"components.Settings.jellyfinSettings": "{mediaServerName} Settings", "components.Settings.jellyfinSettings": "{mediaServerName} Settings",