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

* 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.

* refactor: remove console logs and use getHostname and ApiErrorCodes

* 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.

* refactor: clean up commented out code

* refactor(i18n): extract translation keys

* fix(auth): auth failing with jellyfin login is disabled

* fix(settings): jellyfin migrations replacing the rest of the settings

* fix(settings): jellyfin hostname should be carried out if hostname exists

* fix(settings): merging the wrong settings source

* refactor(settings): use migrator for dynamic settings migrations

* refactor(settingsmigrator): settings migration handler and the migrations

* 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.

* chore(prettierignore): ignore cypress/config/settings.cypress.json as it does not need prettier

* 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.

* test(cypress): add only missing jobs to the cypress settings

* ci: attempt at trying to get formatter to pass on cypress config json file

* refactor: revert the changes brought to try and fix formatter

added back the rest of the cypress settings and removed cypress settings from .prettierignore

* refactor(settings): better erorr logging when jellyfin connection test fails in settings page
This commit is contained in:
Fallenbagel
2024-06-13 22:06:33 +05:00
committed by GitHub
parent a9741fa36d
commit 38ad875dd7
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 {
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(); 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,17 +59,24 @@ 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(
.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, { intl.formatMessage(messages.validationhostrequired, {
mediaServerName: mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin', publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
}) })
), ),
port: Yup.number().required(
intl.formatMessage(messages.validationPortRequired)
),
urlBase: Yup.string()
.matches(
/^(\/[^/].*[^/]$)/,
intl.formatMessage(messages.validationUrlBaseLeadingSlash)
)
.matches(
/^(.*[^/])$/,
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
),
email: Yup.string() email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat)) .email(intl.formatMessage(messages.validationemailformat))
.required(intl.formatMessage(messages.validationemailrequired)), .required(intl.formatMessage(messages.validationemailrequired)),
@@ -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> </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:mb-0 sm:mt-0">
<div className="flex rounded-md shadow-sm"> <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 <Field
id="host" id="hostname"
name="host" name="hostname"
type="text" type="text"
className="rounded-r-only flex-1"
placeholder={intl.formatMessage( placeholder={intl.formatMessage(
messages.host, messages.hostname,
mediaServerFormatValues mediaServerFormatValues
)} )}
/> />
</div> </div>
{errors.host && touched.host && ( {errors.hostname && touched.hostname && (
<div className="error">{errors.host}</div> <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> </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,17 +104,49 @@ 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(
/^(((([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)
), ),
jellyfinInternalUrl: Yup.string().matches( port: Yup.number().when(['hostname'], {
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm, is: (value: unknown) => !!value,
intl.formatMessage(messages.validationUrl) 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('/')
), ),
jellyfinForgotPasswordUrl: Yup.string().matches( jellyfinExternalUrl: Yup.string()
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm, .nullable()
intl.formatMessage(messages.validationUrl) .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('/')
), ),
}); });
@@ -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,6 +469,20 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
} }
); );
} catch (e) { } catch (e) {
if (e.response?.data?.message === ApiErrorCode.InvalidUrl) {
addToast(
intl.formatMessage(messages.invalidurlerror, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
appearance: 'error',
}
);
} else {
addToast( addToast(
intl.formatMessage(messages.jellyfinSettingsFailure, { intl.formatMessage(messages.jellyfinSettingsFailure, {
mediaServerName: mediaServerName:
@@ -432,32 +495,101 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
appearance: 'error', 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",