Compare commits

..

3 Commits

Author SHA1 Message Date
Gauthier
4e741051f3 fix: use undici proxy agent 2024-10-22 23:18:51 +02:00
Gauthier
daecb6b6cf feat: add a proxy option into settings 2024-10-19 00:19:23 +02:00
Gauthier
d99ae35c2e feat: add a proxy option into settings 2024-10-19 00:18:30 +02:00
25 changed files with 309 additions and 690 deletions

1
.gitignore vendored
View File

@@ -34,7 +34,6 @@ yarn-error.log*
# database
config/db/*.sqlite3*
config/settings.json
config/settings.old.json
# logs
config/logs/*.log*

View File

@@ -6,10 +6,6 @@ sidebar_position: 4
# AUR (Arch User Repository)
:::note Disclaimer
This AUR package is not maintained by us but by a third party. Please refer to the maintainer for any issues.
:::
:::info
This method is not recommended for most users. It is intended for advanced users who are using Arch Linux or an Arch-based distribution.
:::

View File

@@ -1988,9 +1988,6 @@ paths:
appDataPath:
type: string
example: /app/config
appDataPermissions:
type: boolean
example: true
/settings/main:
get:
summary: Get main settings

View File

@@ -180,7 +180,7 @@ class PlexAPI {
settings.plex.libraries = [];
}
await settings.save();
settings.save();
}
public async getLibraryContents(

View File

@@ -21,9 +21,7 @@ import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes';
import avatarproxy from '@server/routes/avatarproxy';
import imageproxy from '@server/routes/imageproxy';
import { appDataPermissions } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion';
import createCustomProxyAgent from '@server/utils/customProxyAgent';
import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip';
import { TypeormStore } from 'connect-typeorm/out';
@@ -39,6 +37,7 @@ import dns from 'node:dns';
import net from 'node:net';
import path from 'path';
import swaggerUi from 'swagger-ui-express';
import { ProxyAgent, setGlobalDispatcher } from 'undici';
import YAML from 'yamljs';
if (process.env.forceIpv4First === 'true') {
@@ -53,12 +52,6 @@ const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();
if (!appDataPermissions()) {
logger.error(
'Something went wrong while checking config folder! Please ensure the config folder is set up properly.\nhttps://docs.jellyseerr.dev/getting-started'
);
}
app
.prepare()
.then(async () => {
@@ -76,8 +69,8 @@ app
restartFlag.initializeSettings(settings.main);
// Register HTTP proxy
if (settings.main.proxy.enabled) {
await createCustomProxyAgent(settings.main.proxy);
if (settings.main.httpProxy) {
setGlobalDispatcher(new ProxyAgent(settings.main.httpProxy));
}
// Migrate library types

View File

@@ -135,7 +135,6 @@ class ImageProxy {
private cacheVersion;
private key;
private baseUrl;
private headers: HeadersInit | null = null;
constructor(
key: string,
@@ -143,7 +142,6 @@ class ImageProxy {
options: {
cacheVersion?: number;
rateLimitOptions?: RateLimitOptions;
headers?: HeadersInit;
} = {}
) {
this.cacheVersion = options.cacheVersion ?? 1;
@@ -157,13 +155,9 @@ class ImageProxy {
} else {
this.fetch = fetch;
}
this.headers = options.headers || null;
}
public async getImage(
path: string,
fallbackPath?: string
): Promise<ImageResponse> {
public async getImage(path: string): Promise<ImageResponse> {
const cacheKey = this.getCacheKey(path);
const imageResponse = await this.get(cacheKey);
@@ -172,11 +166,7 @@ class ImageProxy {
const newImage = await this.set(path, cacheKey);
if (!newImage) {
if (fallbackPath) {
return await this.getImage(fallbackPath);
} else {
throw new Error('Failed to load image');
}
throw new Error('Failed to load image');
}
return newImage;
@@ -257,12 +247,7 @@ class ImageProxy {
: '/'
: '') +
(path.startsWith('/') ? path.slice(1) : path);
const response = await this.fetch(href, {
headers: this.headers || undefined,
});
if (!response.ok) {
return null;
}
const response = await this.fetch(href);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);

View File

@@ -129,7 +129,7 @@ class PlexScanner
});
settings.plex.libraries = newLibraries;
await settings.save();
settings.save();
}
} else {
for (const library of this.libraries) {

View File

@@ -2,7 +2,7 @@ import { MediaServerType } from '@server/constants/server';
import { Permission } from '@server/lib/permissions';
import { runMigrations } from '@server/lib/settings/migrator';
import { randomUUID } from 'crypto';
import fs from 'fs/promises';
import fs from 'fs';
import { merge } from 'lodash';
import path from 'path';
import webpush from 'web-push';
@@ -99,17 +99,6 @@ interface Quota {
quotaDays?: number;
}
export interface ProxySettings {
enabled: boolean;
hostname: string;
port: number;
useSsl: boolean;
user: string;
password: string;
bypassFilter: string;
bypassLocalAddresses: boolean;
}
export interface MainSettings {
apiKey: string;
applicationTitle: string;
@@ -130,7 +119,7 @@ export interface MainSettings {
mediaServerType: number;
partialRequestsEnabled: boolean;
locale: string;
proxy: ProxySettings;
httpProxy: string;
}
interface PublicSettings {
@@ -337,16 +326,7 @@ class Settings {
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
locale: 'en',
proxy: {
enabled: false,
hostname: '',
port: 8080,
useSsl: false,
user: '',
password: '',
bypassFilter: '',
bypassLocalAddresses: true,
},
httpProxy: '',
},
plex: {
name: '',
@@ -501,6 +481,10 @@ class Settings {
}
get main(): MainSettings {
if (!this.data.main.apiKey) {
this.data.main.apiKey = this.generateApiKey();
this.save();
}
return this.data.main;
}
@@ -602,20 +586,29 @@ class Settings {
}
get clientId(): string {
if (!this.data.clientId) {
this.data.clientId = randomUUID();
this.save();
}
return this.data.clientId;
}
get vapidPublic(): string {
this.generateVapidKeys();
return this.data.vapidPublic;
}
get vapidPrivate(): string {
this.generateVapidKeys();
return this.data.vapidPrivate;
}
public async regenerateApiKey(): Promise<MainSettings> {
public regenerateApiKey(): MainSettings {
this.main.apiKey = this.generateApiKey();
await this.save();
this.save();
return this.main;
}
@@ -627,6 +620,15 @@ class Settings {
}
}
private generateVapidKeys(force = false): void {
if (!this.data.vapidPublic || !this.data.vapidPrivate || force) {
const vapidKeys = webpush.generateVAPIDKeys();
this.data.vapidPrivate = vapidKeys.privateKey;
this.data.vapidPublic = vapidKeys.publicKey;
this.save();
}
}
/**
* Settings Load
*
@@ -641,51 +643,30 @@ class Settings {
return this;
}
let data;
try {
data = await fs.readFile(SETTINGS_PATH, 'utf-8');
} catch {
await this.save();
if (!fs.existsSync(SETTINGS_PATH)) {
this.save();
}
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
if (data) {
const parsedJson = JSON.parse(data);
const migratedData = await runMigrations(parsedJson, SETTINGS_PATH);
this.data = merge(this.data, migratedData);
}
this.data = await runMigrations(parsedJson, SETTINGS_PATH);
// generate keys and ids if it's missing
let change = false;
if (!this.data.main.apiKey) {
this.data.main.apiKey = this.generateApiKey();
change = true;
} else if (process.env.API_KEY) {
if (this.main.apiKey != process.env.API_KEY) {
this.main.apiKey = process.env.API_KEY;
this.data = merge(this.data, parsedJson);
if (process.env.API_KEY) {
if (this.main.apiKey != process.env.API_KEY) {
this.main.apiKey = process.env.API_KEY;
}
}
}
if (!this.data.clientId) {
this.data.clientId = randomUUID();
change = true;
}
if (!this.data.vapidPublic || !this.data.vapidPrivate) {
const vapidKeys = webpush.generateVAPIDKeys();
this.data.vapidPrivate = vapidKeys.privateKey;
this.data.vapidPublic = vapidKeys.publicKey;
change = true;
}
if (change) {
await this.save();
}
this.save();
}
return this;
}
public async save(): Promise<void> {
await fs.writeFile(
SETTINGS_PATH,
JSON.stringify(this.data, undefined, ' ')
);
public save(): void {
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(this.data, undefined, ' '));
}
}

View File

@@ -1,14 +1,15 @@
import type { AllSettings } from '@server/lib/settings';
const migrateHostname = (settings: any): AllSettings => {
if (settings.jellyfin?.hostname) {
const { hostname } = settings.jellyfin;
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 settings.jellyfin.hostname;
delete oldJellyfinSettings.hostname;
if (urlMatch) {
const [, ip, , port, urlBase] = urlMatch;
settings.jellyfin = {
@@ -20,7 +21,9 @@ const migrateHostname = (settings: any): AllSettings => {
};
}
}
if (settings.jellyfin && settings.jellyfin.hostname) {
delete settings.jellyfin.hostname;
}
return settings;
};

View File

@@ -1,7 +1,7 @@
/* eslint-disable no-console */
import type { AllSettings } from '@server/lib/settings';
import logger from '@server/logger';
import fs from 'fs/promises';
import fs from 'fs';
import path from 'path';
const migrationsDir = path.join(__dirname, 'migrations');
@@ -10,46 +10,19 @@ export const runMigrations = async (
settings: AllSettings,
SETTINGS_PATH: string
): Promise<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;
try {
// we read old backup and create a backup of currents settings
const BACKUP_PATH = SETTINGS_PATH.replace('.json', '.old.json');
let oldBackup: string | null = null;
try {
oldBackup = await fs.readFile(BACKUP_PATH, 'utf-8');
} catch {
/* empty */
}
await fs.writeFile(BACKUP_PATH, JSON.stringify(settings, undefined, ' '));
const migrations = (await fs.readdir(migrationsDir)).filter(
(file) => file.endsWith('.js') || file.endsWith('.ts')
);
const settingsBefore = JSON.stringify(migrated);
for (const migration of migrations) {
try {
logger.debug(`Checking migration '${migration}'...`, {
label: 'Settings Migrator',
});
const { default: migrationFn } = await import(
path.join(migrationsDir, migration)
);
const newSettings = await migrationFn(structuredClone(migrated));
if (JSON.stringify(migrated) !== JSON.stringify(newSettings)) {
logger.debug(`Migration '${migration}' has been applied.`, {
label: 'Settings Migrator',
});
}
migrated = newSettings;
} catch (e) {
logger.error(`Error while running migration '${migration}'`, {
label: 'Settings Migrator',
});
throw e;
}
migrated = await migration(migrated);
}
const settingsAfter = JSON.stringify(migrated);
@@ -57,19 +30,12 @@ export const runMigrations = async (
if (settingsBefore !== settingsAfter) {
// a migration occured
// we check that the new config will be saved
await fs.writeFile(
SETTINGS_PATH,
JSON.stringify(migrated, undefined, ' ')
);
const fileSaved = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(migrated, undefined, ' '));
const fileSaved = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8'));
if (JSON.stringify(fileSaved) !== settingsAfter) {
// something went wrong while saving file
throw new Error('Unable to save settings after migration.');
}
} else if (oldBackup) {
// no migration occured
// we save the old backup (to avoid settings.json and settings.old.json being the same)
await fs.writeFile(BACKUP_PATH, oldBackup.toString());
}
} catch (e) {
logger.error(

View File

@@ -6,6 +6,7 @@ import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { startJobs } from '@server/job/schedule';
import ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
@@ -14,6 +15,7 @@ import { ApiError } from '@server/types/error';
import { getHostname } from '@server/utils/getHostname';
import * as EmailValidator from 'email-validator';
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import net from 'net';
const authRoutes = Router();
@@ -87,7 +89,7 @@ authRoutes.post('/plex', async (req, res, next) => {
});
settings.main.mediaServerType = MediaServerType.PLEX;
await settings.save();
settings.save();
startJobs();
await userRepository.save(user);
@@ -326,7 +328,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`,
avatar: account.User.PrimaryImageTag
? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email || account.User.Name, {
default: 'mm',
size: 200,
}),
userType: UserType.EMBY,
});
@@ -340,7 +347,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`,
avatar: account.User.PrimaryImageTag
? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email || account.User.Name, {
default: 'mm',
size: 200,
}),
userType: UserType.JELLYFIN,
});
@@ -366,7 +378,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
settings.jellyfin.urlBase = body.urlBase ?? '';
settings.jellyfin.useSsl = body.useSsl ?? false;
settings.jellyfin.apiKey = apiKey;
await settings.save();
settings.save();
startJobs();
await userRepository.save(user);
@@ -389,7 +401,27 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUsername: account.User.Name,
}
);
user.avatar = `/avatarproxy/${account.User.Id}`;
// Update the users avatar with their jellyfin profile pic (incase it changed)
if (account.User.PrimaryImageTag) {
const avatar = `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
if (avatar !== user.avatar) {
const avatarProxy = new ImageProxy('avatar', '');
avatarProxy.clearCachedImage(user.avatar);
}
user.avatar = avatar;
} else {
const avatar = gravatarUrl(user.email || account.User.Name, {
default: 'mm',
size: 200,
});
if (avatar !== user.avatar) {
const avatarProxy = new ImageProxy('avatar', '');
avatarProxy.clearCachedImage(user.avatar);
}
user.avatar = avatar;
}
user.jellyfinUsername = account.User.Name;
if (user.username === account.User.Name) {
@@ -427,7 +459,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
permissions: settings.main.defaultPermissions,
avatar: `/avatarproxy/${account.User.Id}`,
avatar: account.User.PrimaryImageTag
? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email || account.User.Name, {
default: 'mm',
size: 200,
}),
userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN

View File

@@ -1,39 +1,21 @@
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import ImageProxy from '@server/lib/imageproxy';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { getAppVersion } from '@server/utils/appVersion';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
const router = Router();
let _avatarImageProxy: ImageProxy | null = null;
async function initAvatarImageProxy() {
if (!_avatarImageProxy) {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
where: { id: 1 },
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' },
});
const deviceId = admin?.jellyfinDeviceId;
const authToken = getSettings().jellyfin.apiKey;
_avatarImageProxy = new ImageProxy('avatar', '', {
headers: {
'X-Emby-Authorization': `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`,
},
});
}
return _avatarImageProxy;
}
router.get('/:jellyfinUserId', async (req, res) => {
const avatarImageProxy = new ImageProxy('avatar', '');
// Proxy avatar images
router.get('/*', async (req, res) => {
let imagePath = '';
try {
if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) {
const jellyfinAvatar = req.url.match(
/(\/Users\/\w+\/Images\/Primary\/?\?tag=\w+&quality=90)$/
)?.[1];
if (!jellyfinAvatar) {
const mediaServerType = getSettings().main.mediaServerType;
throw new Error(
`Provided URL is not ${
@@ -44,28 +26,10 @@ router.get('/:jellyfinUserId', async (req, res) => {
);
}
const avatarImageCache = await initAvatarImageProxy();
const imageUrl = new URL(jellyfinAvatar, getHostname());
imagePath = imageUrl.toString();
const user = await getRepository(User).findOne({
where: { jellyfinUserId: req.params.jellyfinUserId },
});
const fallbackUrl = gravatarUrl(user?.email || 'none', {
default: 'mm',
size: 200,
});
const jellyfinAvatarUrl = `${getHostname()}/UserImage?UserId=${
req.params.jellyfinUserId
}`;
let imageData = await avatarImageCache.getImage(
jellyfinAvatarUrl,
fallbackUrl
);
if (imageData.meta.extension === 'json') {
// this is a 404
imageData = await avatarImageCache.getImage(fallbackUrl);
}
const imageData = await avatarImageProxy.getImage(imagePath);
res.writeHead(200, {
'Content-Type': `image/${imageData.meta.extension}`,
@@ -78,6 +42,7 @@ router.get('/:jellyfinUserId', async (req, res) => {
res.end(imageData.imageBuffer);
} catch (e) {
logger.error('Failed to proxy avatar image', {
imagePath,
errorMessage: e.message,
});
}

View File

@@ -17,11 +17,7 @@ import { mapProductionCompany } from '@server/models/Movie';
import { mapNetwork } from '@server/models/Tv';
import settingsRoutes from '@server/routes/settings';
import watchlistRoutes from '@server/routes/watchlist';
import {
appDataPath,
appDataPermissions,
appDataStatus,
} from '@server/utils/appDataVolume';
import { appDataPath, appDataStatus } from '@server/utils/appDataVolume';
import { getAppVersion, getCommitTag } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag';
import { isPerson } from '@server/utils/typeHelpers';
@@ -97,7 +93,6 @@ router.get('/status/appdata', (_req, res) => {
return res.status(200).json({
appData: appDataStatus(),
appDataPath: appDataPath(),
appDataPermissions: appDataPermissions(),
});
});

View File

@@ -123,13 +123,9 @@ serviceRoutes.get<{ sonarrId: string }>(
});
try {
const systemStatus = await sonarr.getSystemStatus();
const sonarrMajorVersion = Number(systemStatus.version.split('.')[0]);
const profiles = await sonarr.getProfiles();
const rootFolders = await sonarr.getRootFolders();
const languageProfiles =
sonarrMajorVersion <= 3 ? await sonarr.getLanguageProfiles() : null;
const languageProfiles = await sonarr.getLanguageProfiles();
const tags = await sonarr.getTags();
return res.status(200).json({

View File

@@ -32,6 +32,7 @@ import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import fs from 'fs';
import gravatarUrl from 'gravatar-url';
import { escapeRegExp, merge, omit, set, sortBy } from 'lodash';
import { rescheduleJob } from 'node-schedule';
import path from 'path';
@@ -69,19 +70,19 @@ settingsRoutes.get('/main', (req, res, next) => {
res.status(200).json(filteredMainSettings(req.user, settings.main));
});
settingsRoutes.post('/main', async (req, res) => {
settingsRoutes.post('/main', (req, res) => {
const settings = getSettings();
settings.main = merge(settings.main, req.body);
await settings.save();
settings.save();
return res.status(200).json(settings.main);
});
settingsRoutes.post('/main/regenerate', async (req, res, next) => {
settingsRoutes.post('/main/regenerate', (req, res, next) => {
const settings = getSettings();
const main = await settings.regenerateApiKey();
const main = settings.regenerateApiKey();
if (!req.user) {
return next({ status: 500, message: 'User missing from request.' });
@@ -118,7 +119,7 @@ settingsRoutes.post('/plex', async (req, res, next) => {
settings.plex.machineId = result.MediaContainer.machineIdentifier;
settings.plex.name = result.MediaContainer.friendlyName;
await settings.save();
settings.save();
} catch (e) {
logger.error('Something went wrong testing Plex connection', {
label: 'API',
@@ -231,7 +232,7 @@ settingsRoutes.get('/plex/library', async (req, res) => {
...library,
enabled: enabledLibraries.includes(library.id),
}));
await settings.save();
settings.save();
return res.status(200).json(settings.plex.libraries);
});
@@ -282,7 +283,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
Object.assign(settings.jellyfin, req.body);
settings.jellyfin.serverId = result.Id;
settings.jellyfin.name = result.ServerName;
await settings.save();
settings.save();
} catch (e) {
if (e instanceof ApiError) {
logger.error('Something went wrong testing Jellyfin connection', {
@@ -370,7 +371,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
...library,
enabled: enabledLibraries.includes(library.id),
}));
await settings.save();
settings.save();
return res.status(200).json(settings.jellyfin.libraries);
});
@@ -394,7 +395,9 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
const users = resp.users.map((user) => ({
username: user.Name,
id: user.Id,
thumb: `/avatarproxy/${user.Id}`,
thumb: user.PrimaryImageTag
? `/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
: gravatarUrl(user.Name, { default: 'mm', size: 200 }),
email: user.Name,
}));
@@ -434,7 +437,7 @@ settingsRoutes.post('/tautulli', async (req, res, next) => {
throw new Error('Tautulli version not supported');
}
await settings.save();
settings.save();
} catch (e) {
logger.error('Something went wrong testing Tautulli connection', {
label: 'API',
@@ -695,7 +698,7 @@ settingsRoutes.post<{ jobId: JobId }>(
settingsRoutes.post<{ jobId: JobId }>(
'/jobs/:jobId/schedule',
async (req, res, next) => {
(req, res, next) => {
const scheduledJob = scheduledJobs.find(
(job) => job.id === req.params.jobId
);
@@ -709,7 +712,7 @@ settingsRoutes.post<{ jobId: JobId }>(
if (result) {
settings.jobs[scheduledJob.id].schedule = req.body.schedule;
await settings.save();
settings.save();
scheduledJob.cronSchedule = req.body.schedule;
@@ -766,11 +769,11 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
settingsRoutes.post(
'/initialize',
isAuthenticated(Permission.ADMIN),
async (_req, res) => {
(_req, res) => {
const settings = getSettings();
settings.public.initialized = true;
await settings.save();
settings.save();
return res.status(200).json(settings.public);
}

View File

@@ -31,11 +31,11 @@ notificationRoutes.get('/discord', (_req, res) => {
res.status(200).json(settings.notifications.agents.discord);
});
notificationRoutes.post('/discord', async (req, res) => {
notificationRoutes.post('/discord', (req, res) => {
const settings = getSettings();
settings.notifications.agents.discord = req.body;
await settings.save();
settings.save();
res.status(200).json(settings.notifications.agents.discord);
});
@@ -65,11 +65,11 @@ notificationRoutes.get('/slack', (_req, res) => {
res.status(200).json(settings.notifications.agents.slack);
});
notificationRoutes.post('/slack', async (req, res) => {
notificationRoutes.post('/slack', (req, res) => {
const settings = getSettings();
settings.notifications.agents.slack = req.body;
await settings.save();
settings.save();
res.status(200).json(settings.notifications.agents.slack);
});
@@ -99,11 +99,11 @@ notificationRoutes.get('/telegram', (_req, res) => {
res.status(200).json(settings.notifications.agents.telegram);
});
notificationRoutes.post('/telegram', async (req, res) => {
notificationRoutes.post('/telegram', (req, res) => {
const settings = getSettings();
settings.notifications.agents.telegram = req.body;
await settings.save();
settings.save();
res.status(200).json(settings.notifications.agents.telegram);
});
@@ -133,11 +133,11 @@ notificationRoutes.get('/pushbullet', (_req, res) => {
res.status(200).json(settings.notifications.agents.pushbullet);
});
notificationRoutes.post('/pushbullet', async (req, res) => {
notificationRoutes.post('/pushbullet', (req, res) => {
const settings = getSettings();
settings.notifications.agents.pushbullet = req.body;
await settings.save();
settings.save();
res.status(200).json(settings.notifications.agents.pushbullet);
});
@@ -167,11 +167,11 @@ notificationRoutes.get('/pushover', (_req, res) => {
res.status(200).json(settings.notifications.agents.pushover);
});
notificationRoutes.post('/pushover', async (req, res) => {
notificationRoutes.post('/pushover', (req, res) => {
const settings = getSettings();
settings.notifications.agents.pushover = req.body;
await settings.save();
settings.save();
res.status(200).json(settings.notifications.agents.pushover);
});
@@ -201,11 +201,11 @@ notificationRoutes.get('/email', (_req, res) => {
res.status(200).json(settings.notifications.agents.email);
});
notificationRoutes.post('/email', async (req, res) => {
notificationRoutes.post('/email', (req, res) => {
const settings = getSettings();
settings.notifications.agents.email = req.body;
await settings.save();
settings.save();
res.status(200).json(settings.notifications.agents.email);
});
@@ -235,11 +235,11 @@ notificationRoutes.get('/webpush', (_req, res) => {
res.status(200).json(settings.notifications.agents.webpush);
});
notificationRoutes.post('/webpush', async (req, res) => {
notificationRoutes.post('/webpush', (req, res) => {
const settings = getSettings();
settings.notifications.agents.webpush = req.body;
await settings.save();
settings.save();
res.status(200).json(settings.notifications.agents.webpush);
});
@@ -284,7 +284,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
res.status(200).json(response);
});
notificationRoutes.post('/webhook', async (req, res, next) => {
notificationRoutes.post('/webhook', (req, res, next) => {
const settings = getSettings();
try {
JSON.parse(req.body.options.jsonPayload);
@@ -300,7 +300,7 @@ notificationRoutes.post('/webhook', async (req, res, next) => {
authHeader: req.body.options.authHeader,
},
};
await settings.save();
settings.save();
res.status(200).json(settings.notifications.agents.webhook);
} catch (e) {
@@ -351,11 +351,11 @@ notificationRoutes.get('/lunasea', (_req, res) => {
res.status(200).json(settings.notifications.agents.lunasea);
});
notificationRoutes.post('/lunasea', async (req, res) => {
notificationRoutes.post('/lunasea', (req, res) => {
const settings = getSettings();
settings.notifications.agents.lunasea = req.body;
await settings.save();
settings.save();
res.status(200).json(settings.notifications.agents.lunasea);
});
@@ -385,11 +385,11 @@ notificationRoutes.get('/gotify', (_req, res) => {
res.status(200).json(settings.notifications.agents.gotify);
});
notificationRoutes.post('/gotify', async (req, res) => {
notificationRoutes.post('/gotify', (req, res) => {
const settings = getSettings();
settings.notifications.agents.gotify = req.body;
await settings.save();
settings.save();
res.status(200).json(settings.notifications.agents.gotify);
});

View File

@@ -12,7 +12,7 @@ radarrRoutes.get('/', (_req, res) => {
res.status(200).json(settings.radarr);
});
radarrRoutes.post('/', async (req, res) => {
radarrRoutes.post('/', (req, res) => {
const settings = getSettings();
const newRadarr = req.body as RadarrSettings;
@@ -31,7 +31,7 @@ radarrRoutes.post('/', async (req, res) => {
}
settings.radarr = [...settings.radarr, newRadarr];
await settings.save();
settings.save();
return res.status(201).json(newRadarr);
});
@@ -76,7 +76,7 @@ radarrRoutes.post<
radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>(
'/:id',
async (req, res, next) => {
(req, res, next) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
@@ -102,7 +102,7 @@ radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>(
...req.body,
id: Number(req.params.id),
} as RadarrSettings;
await settings.save();
settings.save();
return res.status(200).json(settings.radarr[radarrIndex]);
}
@@ -134,7 +134,7 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => {
);
});
radarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => {
radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
@@ -146,7 +146,7 @@ radarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => {
}
const removed = settings.radarr.splice(radarrIndex, 1);
await settings.save();
settings.save();
return res.status(200).json(removed[0]);
});

View File

@@ -12,7 +12,7 @@ sonarrRoutes.get('/', (_req, res) => {
res.status(200).json(settings.sonarr);
});
sonarrRoutes.post('/', async (req, res) => {
sonarrRoutes.post('/', (req, res) => {
const settings = getSettings();
const newSonarr = req.body as SonarrSettings;
@@ -31,7 +31,7 @@ sonarrRoutes.post('/', async (req, res) => {
}
settings.sonarr = [...settings.sonarr, newSonarr];
await settings.save();
settings.save();
return res.status(201).json(newSonarr);
});
@@ -43,14 +43,13 @@ sonarrRoutes.post('/test', async (req, res, next) => {
url: SonarrAPI.buildUrl(req.body, '/api/v3'),
});
const systemStatus = await sonarr.getSystemStatus();
const sonarrMajorVersion = Number(systemStatus.version.split('.')[0]);
const urlBase = systemStatus.urlBase;
const urlBase = await sonarr
.getSystemStatus()
.then((value) => value.urlBase)
.catch(() => req.body.baseUrl);
const profiles = await sonarr.getProfiles();
const folders = await sonarr.getRootFolders();
const languageProfiles =
sonarrMajorVersion <= 3 ? await sonarr.getLanguageProfiles() : null;
const languageProfiles = await sonarr.getLanguageProfiles();
const tags = await sonarr.getTags();
return res.status(200).json({
@@ -73,7 +72,7 @@ sonarrRoutes.post('/test', async (req, res, next) => {
}
});
sonarrRoutes.put<{ id: string }>('/:id', async (req, res) => {
sonarrRoutes.put<{ id: string }>('/:id', (req, res) => {
const settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex(
@@ -101,12 +100,12 @@ sonarrRoutes.put<{ id: string }>('/:id', async (req, res) => {
...req.body,
id: Number(req.params.id),
} as SonarrSettings;
await settings.save();
settings.save();
return res.status(200).json(settings.sonarr[sonarrIndex]);
});
sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => {
sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
const settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex(
@@ -120,7 +119,7 @@ sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => {
}
const removed = settings.sonarr.splice(sonarrIndex, 1);
await settings.save();
settings.save();
return res.status(200).json(removed[0]);
});

View File

@@ -539,7 +539,12 @@ router.post(
).toString('base64'),
email: jellyfinUser?.Name,
permissions: settings.main.defaultPermissions,
avatar: `/avatarproxy/${jellyfinUser?.Id}`,
avatar: jellyfinUser?.PrimaryImageTag
? `/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
: gravatarUrl(jellyfinUser?.Name ?? '', {
default: 'mm',
size: 200,
}),
userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN

View File

@@ -1,4 +1,4 @@
import { accessSync, existsSync } from 'fs';
import { existsSync } from 'fs';
import path from 'path';
const CONFIG_PATH = process.env.CONFIG_DIRECTORY
@@ -14,12 +14,3 @@ export const appDataStatus = (): boolean => {
export const appDataPath = (): string => {
return CONFIG_PATH;
};
export const appDataPermissions = (): boolean => {
try {
accessSync(CONFIG_PATH);
return true;
} catch (err) {
return false;
}
};

View File

@@ -1,111 +0,0 @@
import type { ProxySettings } from '@server/lib/settings';
import logger from '@server/logger';
import type { Dispatcher } from 'undici';
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
export default async function createCustomProxyAgent(
proxySettings: ProxySettings
) {
const defaultAgent = new Agent();
const skipUrl = (url: string) => {
const hostname = new URL(url).hostname;
if (proxySettings.bypassLocalAddresses && isLocalAddress(hostname)) {
return true;
}
for (const address of proxySettings.bypassFilter.split(',')) {
const trimmedAddress = address.trim();
if (!trimmedAddress) {
continue;
}
if (trimmedAddress.startsWith('*')) {
const domain = trimmedAddress.slice(1);
if (hostname.endsWith(domain)) {
return true;
}
} else if (hostname === trimmedAddress) {
return true;
}
}
return false;
};
const noProxyInterceptor = (
dispatch: Dispatcher['dispatch']
): Dispatcher['dispatch'] => {
return (opts, handler) => {
const url = opts.origin?.toString();
return url && skipUrl(url)
? defaultAgent.dispatch(opts, handler)
: dispatch(opts, handler);
};
};
const token =
proxySettings.user && proxySettings.password
? `Basic ${Buffer.from(
`${proxySettings.user}:${proxySettings.password}`
).toString('base64')}`
: undefined;
try {
const proxyAgent = new ProxyAgent({
uri:
(proxySettings.useSsl ? 'https://' : 'http://') +
proxySettings.hostname +
':' +
proxySettings.port,
token,
interceptors: {
Client: [noProxyInterceptor],
},
});
setGlobalDispatcher(proxyAgent);
} catch (e) {
logger.error('Failed to connect to the proxy: ' + e.message, {
label: 'Proxy',
});
setGlobalDispatcher(defaultAgent);
return;
}
try {
const res = await fetch('https://www.google.com', { method: 'HEAD' });
if (res.ok) {
logger.debug('HTTP(S) proxy connected successfully', { label: 'Proxy' });
} else {
logger.error('Proxy responded, but with a non-OK status: ' + res.status, {
label: 'Proxy',
});
setGlobalDispatcher(defaultAgent);
}
} catch (e) {
logger.error(
'Failed to connect to the proxy: ' + e.message + ': ' + e.cause,
{ label: 'Proxy' }
);
setGlobalDispatcher(defaultAgent);
}
}
function isLocalAddress(hostname: string) {
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return true;
}
const privateIpRanges = [
/^10\./, // 10.x.x.x
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.x.x - 172.31.x.x
/^192\.168\./, // 192.168.x.x
];
if (privateIpRanges.some((regex) => regex.test(hostname))) {
return true;
}
return false;
}

View File

@@ -14,7 +14,7 @@ class RestartFlag {
return (
this.settings.csrfProtection !== settings.csrfProtection ||
this.settings.trustProxy !== settings.trustProxy ||
this.settings.proxy.enabled !== settings.proxy.enabled
this.settings.httpProxy !== settings.httpProxy
);
}
}

View File

@@ -25,8 +25,11 @@ const CachedImage = ({ src, type, ...props }: CachedImageProps) => {
? src.replace(/^https:\/\/image\.tmdb\.org\//, '/imageproxy/')
: src;
} else if (type === 'avatar') {
// jellyfin avatar (if any)
imageUrl = src;
// jellyfin avatar (in any)
const jellyfinAvatar = src.match(
/(\/Users\/\w+\/Images\/Primary\/?\?tag=\w+&quality=90)$/
)?.[1];
imageUrl = jellyfinAvatar ? `/avatarproxy` + jellyfinAvatar : src;
} else {
return null;
}

View File

@@ -55,17 +55,8 @@ const messages = defineMessages('components.Settings.SettingsMain', {
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
partialRequestsEnabled: 'Allow Partial Series Requests',
locale: 'Display Language',
proxyEnabled: 'HTTP(S) Proxy',
proxyHostname: 'Proxy Hostname',
proxyPort: 'Proxy Port',
proxySsl: 'Use SSL For Proxy',
proxyUser: 'Proxy Username',
proxyPassword: 'Proxy Password',
proxyBypassFilter: 'Proxy Ignored Addresses',
proxyBypassFilterTip:
"Use ',' as a separator, and '*.' as a wildcard for subdomains",
proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses',
validationProxyPort: 'You must provide a valid port',
httpProxy: 'HTTP Proxy',
httpProxyTip: 'Tooltip to write',
});
const SettingsMain = () => {
@@ -93,12 +84,9 @@ const SettingsMain = () => {
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
(value) => !value || !value.endsWith('/')
),
proxyPort: Yup.number().when('proxyEnabled', {
is: (proxyEnabled: boolean) => proxyEnabled,
then: Yup.number().required(
intl.formatMessage(messages.validationProxyPort)
),
}),
httpProxy: Yup.string().url(
intl.formatMessage(messages.validationApplicationUrl)
),
});
const regenerate = async () => {
@@ -154,14 +142,7 @@ const SettingsMain = () => {
partialRequestsEnabled: data?.partialRequestsEnabled,
trustProxy: data?.trustProxy,
cacheImages: data?.cacheImages,
proxyEnabled: data?.proxy?.enabled,
proxyHostname: data?.proxy?.hostname,
proxyPort: data?.proxy?.port,
proxySsl: data?.proxy?.useSsl,
proxyUser: data?.proxy?.user,
proxyPassword: data?.proxy?.password,
proxyBypassFilter: data?.proxy?.bypassFilter,
proxyBypassLocalAddresses: data?.proxy?.bypassLocalAddresses,
httpProxy: data?.httpProxy,
}}
enableReinitialize
validationSchema={MainSettingsSchema}
@@ -183,16 +164,7 @@ const SettingsMain = () => {
partialRequestsEnabled: values.partialRequestsEnabled,
trustProxy: values.trustProxy,
cacheImages: values.cacheImages,
proxy: {
enabled: values.proxyEnabled,
hostname: values.proxyHostname,
port: values.proxyPort,
useSsl: values.proxySsl,
user: values.proxyUser,
password: values.proxyPassword,
bypassFilter: values.proxyBypassFilter,
bypassLocalAddresses: values.proxyBypassLocalAddresses,
},
httpProxy: values.httpProxy,
}),
});
if (!res.ok) throw new Error();
@@ -473,175 +445,27 @@ const SettingsMain = () => {
</div>
</div>
<div className="form-row">
<label htmlFor="proxyEnabled" className="checkbox-label">
<label htmlFor="httpProxy" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.proxyEnabled)}
{intl.formatMessage(messages.httpProxy)}
</span>
<SettingsBadge badgeType="advanced" className="mr-2" />
<SettingsBadge badgeType="restartRequired" />
<span className="label-tip">
{intl.formatMessage(messages.httpProxyTip)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="proxyEnabled"
name="proxyEnabled"
onChange={() => {
setFieldValue('proxyEnabled', !values.proxyEnabled);
}}
/>
<div className="form-input-field">
<Field id="httpProxy" name="httpProxy" type="text" />
</div>
{errors.httpProxy &&
touched.httpProxy &&
typeof errors.httpProxy === 'string' && (
<div className="error">{errors.httpProxy}</div>
)}
</div>
</div>
{values.proxyEnabled && (
<>
<div className="form-row">
<label htmlFor="proxyHostname" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyHostname)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="proxyHostname"
name="proxyHostname"
type="text"
/>
</div>
{errors.proxyHostname &&
touched.proxyHostname &&
typeof errors.proxyHostname === 'string' && (
<div className="error">{errors.proxyHostname}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="proxyPort" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyPort)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="proxyPort" name="proxyPort" type="text" />
</div>
{errors.proxyPort &&
touched.proxyPort &&
typeof errors.proxyPort === 'string' && (
<div className="error">{errors.proxyPort}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="proxySsl" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxySsl)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="proxySsl"
name="proxySsl"
onChange={() => {
setFieldValue('proxySsl', !values.proxySsl);
}}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="proxyUser" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyUser)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="proxyUser" name="proxyUser" type="text" />
</div>
{errors.proxyUser &&
touched.proxyUser &&
typeof errors.proxyUser === 'string' && (
<div className="error">{errors.proxyUser}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="proxyPassword" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyPassword)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="proxyPassword"
name="proxyPassword"
type="password"
/>
</div>
{errors.proxyPassword &&
touched.proxyPassword &&
typeof errors.proxyPassword === 'string' && (
<div className="error">{errors.proxyPassword}</div>
)}
</div>
</div>
<div className="form-row">
<label
htmlFor="proxyBypassFilter"
className="checkbox-label"
>
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyBypassFilter)}
</span>
<span className="label-tip ml-4">
{intl.formatMessage(messages.proxyBypassFilterTip)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="proxyBypassFilter"
name="proxyBypassFilter"
type="text"
/>
</div>
{errors.proxyBypassFilter &&
touched.proxyBypassFilter &&
typeof errors.proxyBypassFilter === 'string' && (
<div className="error">
{errors.proxyBypassFilter}
</div>
)}
</div>
</div>
<div className="form-row">
<label
htmlFor="proxyBypassLocalAddresses"
className="checkbox-label"
>
<span className="mr-2 ml-4">
{intl.formatMessage(
messages.proxyBypassLocalAddresses
)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="proxyBypassLocalAddresses"
name="proxyBypassLocalAddresses"
onChange={() => {
setFieldValue(
'proxyBypassLocalAddresses',
!values.proxyBypassLocalAddresses
);
}}
/>
</div>
</div>
</>
)}
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">

View File

@@ -86,12 +86,10 @@ interface TestResponse {
id: number;
path: string;
}[];
languageProfiles:
| {
id: number;
name: string;
}[]
| null;
languageProfiles: {
id: number;
name: string;
}[];
tags: {
id: number;
label: string;
@@ -114,7 +112,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
const [testResponse, setTestResponse] = useState<TestResponse>({
profiles: [],
rootFolders: [],
languageProfiles: null,
languageProfiles: [],
tags: [],
});
const SonarrSettingsSchema = Yup.object().shape({
@@ -139,11 +137,9 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
activeProfileId: Yup.string().required(
intl.formatMessage(messages.validationProfileRequired)
),
activeLanguageProfileId: testResponse.languageProfiles
? Yup.number().required(
intl.formatMessage(messages.validationLanguageProfileRequired)
)
: Yup.number(),
activeLanguageProfileId: Yup.number().required(
intl.formatMessage(messages.validationLanguageProfileRequired)
),
externalUrl: Yup.string()
.url(intl.formatMessage(messages.validationApplicationUrl))
.test(
@@ -662,56 +658,54 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
)}
</div>
</div>
{testResponse.languageProfiles && (
<div className="form-row">
<label
htmlFor="activeLanguageProfileId"
className="text-label"
>
{intl.formatMessage(messages.languageprofile)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeLanguageProfileId"
name="activeLanguageProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(
messages.loadinglanguageprofiles
)
: !isValidated
? intl.formatMessage(
messages.testFirstLanguageProfiles
)
: intl.formatMessage(
messages.selectLanguageProfile
)}
</option>
{testResponse.languageProfiles.length > 0 &&
testResponse.languageProfiles.map((language) => (
<option
key={`loaded-profile-${language.id}`}
value={language.id}
>
{language.name}
</option>
))}
</Field>
</div>
{errors.activeLanguageProfileId &&
touched.activeLanguageProfileId && (
<div className="error">
{errors.activeLanguageProfileId}
</div>
)}
<div className="form-row">
<label
htmlFor="activeLanguageProfileId"
className="text-label"
>
{intl.formatMessage(messages.languageprofile)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeLanguageProfileId"
name="activeLanguageProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(
messages.loadinglanguageprofiles
)
: !isValidated
? intl.formatMessage(
messages.testFirstLanguageProfiles
)
: intl.formatMessage(
messages.selectLanguageProfile
)}
</option>
{testResponse.languageProfiles.length > 0 &&
testResponse.languageProfiles.map((language) => (
<option
key={`loaded-profile-${language.id}`}
value={language.id}
>
{language.name}
</option>
))}
</Field>
</div>
{errors.activeLanguageProfileId &&
touched.activeLanguageProfileId && (
<div className="error">
{errors.activeLanguageProfileId}
</div>
)}
</div>
)}
</div>
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
@@ -869,55 +863,53 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
)}
</div>
</div>
{testResponse.languageProfiles && (
<div className="form-row">
<label
htmlFor="activeAnimeLanguageProfileId"
className="text-label"
>
{intl.formatMessage(messages.animelanguageprofile)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeAnimeLanguageProfileId"
name="activeAnimeLanguageProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(
messages.loadinglanguageprofiles
)
: !isValidated
? intl.formatMessage(
messages.testFirstLanguageProfiles
)
: intl.formatMessage(
messages.selectLanguageProfile
)}
</option>
{testResponse.languageProfiles.length > 0 &&
testResponse.languageProfiles.map((language) => (
<option
key={`loaded-profile-${language.id}`}
value={language.id}
>
{language.name}
</option>
))}
</Field>
</div>
{errors.activeAnimeLanguageProfileId &&
touched.activeAnimeLanguageProfileId && (
<div className="error">
{errors.activeAnimeLanguageProfileId}
</div>
)}
<div className="form-row">
<label
htmlFor="activeAnimeLanguageProfileId"
className="text-label"
>
{intl.formatMessage(messages.animelanguageprofile)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeAnimeLanguageProfileId"
name="activeAnimeLanguageProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(
messages.loadinglanguageprofiles
)
: !isValidated
? intl.formatMessage(
messages.testFirstLanguageProfiles
)
: intl.formatMessage(
messages.selectLanguageProfile
)}
</option>
{testResponse.languageProfiles.length > 0 &&
testResponse.languageProfiles.map((language) => (
<option
key={`loaded-profile-${language.id}`}
value={language.id}
>
{language.name}
</option>
))}
</Field>
</div>
{errors.activeAnimeLanguageProfileId &&
touched.activeAnimeLanguageProfileId && (
<div className="error">
{errors.activeAnimeLanguageProfileId}
</div>
)}
</div>
)}
</div>
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.animeTags)}