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
39 changed files with 396 additions and 876 deletions

View File

@@ -18,7 +18,7 @@ config/logs/*
config/*.json config/*.json
dist dist
Dockerfile* Dockerfile*
compose.yaml docker-compose.yml
docs docs
LICENSE LICENSE
node_modules node_modules

2
.gitattributes vendored
View File

@@ -40,7 +40,7 @@ docs export-ignore
.all-contributorsrc export-ignore .all-contributorsrc export-ignore
.editorconfig export-ignore .editorconfig export-ignore
Dockerfile.local export-ignore Dockerfile.local export-ignore
compose.yaml export-ignore docker-compose.yml export-ignore
stylelint.config.js export-ignore stylelint.config.js export-ignore
public/os_logo_filled.png export-ignore public/os_logo_filled.png export-ignore

1
.gitignore vendored
View File

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

View File

@@ -52,7 +52,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
pnpm dev pnpm dev
``` ```
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly. - Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
5. Create your patch and test your changes. 5. Create your patch and test your changes.

View File

@@ -1,3 +1,4 @@
version: '3'
services: services:
jellyseerr: jellyseerr:
build: build:

View File

@@ -190,7 +190,7 @@ Caddy will automatically obtain and renew SSL certificates for your domain.
## Traefik (v2) ## Traefik (v2)
Add the following labels to the Jellyseerr service in your `compose.yaml` file: Add the following labels to the Jellyseerr service in your `docker-compose.yml` file:
```yaml ```yaml
labels: labels:

View File

@@ -6,10 +6,6 @@ sidebar_position: 4
# AUR (Arch User Repository) # 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 :::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. 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

@@ -71,7 +71,7 @@ You could also use [diun](https://github.com/crazy-max/diun) to receive notifica
For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/). For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/).
#### Installation: #### Installation:
Define the `jellyseerr` service in your `compose.yaml` as follows: Define the `jellyseerr` service in your `docker-compose.yml` as follows:
```yaml ```yaml
--- ---
services: services:
@@ -94,17 +94,17 @@ If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable
Then, start all services defined in the Compose file: Then, start all services defined in the Compose file:
```bash ```bash
docker compose up -d docker-compose up -d
``` ```
#### Updating: #### Updating:
Pull the latest image: Pull the latest image:
```bash ```bash
docker compose pull jellyseerr docker-compose pull jellyseerr
``` ```
Then, restart all services defined in the Compose file: Then, restart all services defined in the Compose file:
```bash ```bash
docker compose up -d docker-compose up -d
``` ```
:::tip :::tip
You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files. You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files.

View File

@@ -1988,9 +1988,6 @@ paths:
appDataPath: appDataPath:
type: string type: string
example: /app/config example: /app/config
appDataPermissions:
type: boolean
example: true
/settings/main: /settings/main:
get: get:
summary: Get main settings summary: Get main settings
@@ -4142,21 +4139,6 @@ paths:
'412': '412':
description: Item has already been blacklisted description: Item has already been blacklisted
/blacklist/{tmdbId}: /blacklist/{tmdbId}:
get:
summary: Get media from blacklist
tags:
- blacklist
parameters:
- in: path
name: tmdbId
description: tmdbId ID
required: true
example: '1'
schema:
type: string
responses:
'200':
description: Blacklist details in JSON
delete: delete:
summary: Remove media from blacklist summary: Remove media from blacklist
tags: tags:

View File

@@ -32,27 +32,13 @@ class ExternalAPI {
this.fetch = fetch; this.fetch = fetch;
} }
const url = new URL(baseUrl); this.baseUrl = baseUrl;
this.params = params;
this.defaultHeaders = { this.defaultHeaders = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
...((url.username || url.password) && {
Authorization: `Basic ${Buffer.from(
`${url.username}:${url.password}`
).toString('base64')}`,
}),
...options.headers, ...options.headers,
}; };
if (url.username || url.password) {
url.username = '';
url.password = '';
baseUrl = url.toString();
}
this.baseUrl = baseUrl;
this.params = params;
this.cache = options.nodeCache; this.cache = options.nodeCache;
} }

View File

@@ -410,7 +410,7 @@ class JellyfinAPI extends ExternalAPI {
).AccessToken; ).AccessToken;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while creating an API key from the Jellyfin server: ${e.message}`, `Something went wrong while creating an API key the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );

View File

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

View File

@@ -80,12 +80,12 @@ export class Blacklist implements BlacklistItem {
status: MediaStatus.BLACKLISTED, status: MediaStatus.BLACKLISTED,
status4k: MediaStatus.BLACKLISTED, status4k: MediaStatus.BLACKLISTED,
mediaType: blacklistRequest.mediaType, mediaType: blacklistRequest.mediaType,
blacklist: Promise.resolve(blacklist), blacklist: blacklist,
}); });
await mediaRepository.save(media); await mediaRepository.save(media);
} else { } else {
media.blacklist = Promise.resolve(blacklist); media.blacklist = blacklist;
media.status = MediaStatus.BLACKLISTED; media.status = MediaStatus.BLACKLISTED;
media.status4k = MediaStatus.BLACKLISTED; media.status4k = MediaStatus.BLACKLISTED;

View File

@@ -118,8 +118,10 @@ class Media {
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true }) @OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
public issues: Issue[]; public issues: Issue[];
@OneToOne(() => Blacklist, (blacklist) => blacklist.media) @OneToOne(() => Blacklist, (blacklist) => blacklist.media, {
public blacklist: Promise<Blacklist>; eager: true,
})
public blacklist: Blacklist;
@CreateDateColumn() @CreateDateColumn()
public createdAt: Date; public createdAt: Date;

View File

@@ -21,9 +21,7 @@ import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes'; import routes from '@server/routes';
import avatarproxy from '@server/routes/avatarproxy'; import avatarproxy from '@server/routes/avatarproxy';
import imageproxy from '@server/routes/imageproxy'; import imageproxy from '@server/routes/imageproxy';
import { appDataPermissions } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion'; import { getAppVersion } from '@server/utils/appVersion';
import createCustomProxyAgent from '@server/utils/customProxyAgent';
import restartFlag from '@server/utils/restartFlag'; import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip'; import { getClientIp } from '@supercharge/request-ip';
import { TypeormStore } from 'connect-typeorm/out'; import { TypeormStore } from 'connect-typeorm/out';
@@ -39,6 +37,7 @@ import dns from 'node:dns';
import net from 'node:net'; import net from 'node:net';
import path from 'path'; import path from 'path';
import swaggerUi from 'swagger-ui-express'; import swaggerUi from 'swagger-ui-express';
import { ProxyAgent, setGlobalDispatcher } from 'undici';
import YAML from 'yamljs'; import YAML from 'yamljs';
if (process.env.forceIpv4First === 'true') { if (process.env.forceIpv4First === 'true') {
@@ -53,12 +52,6 @@ const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev }); const app = next({ dev });
const handle = app.getRequestHandler(); 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 app
.prepare() .prepare()
.then(async () => { .then(async () => {
@@ -76,8 +69,8 @@ app
restartFlag.initializeSettings(settings.main); restartFlag.initializeSettings(settings.main);
// Register HTTP proxy // Register HTTP proxy
if (settings.main.proxy.enabled) { if (settings.main.httpProxy) {
await createCustomProxyAgent(settings.main.proxy); setGlobalDispatcher(new ProxyAgent(settings.main.httpProxy));
} }
// Migrate library types // Migrate library types

View File

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

View File

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

View File

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

View File

@@ -27,14 +27,8 @@ const migrateApiTokens = async (settings: any): Promise<AllSettings> => {
admin.jellyfinDeviceId admin.jellyfinDeviceId
); );
jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
try { const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
const apiKey = await jellyfinClient.createApiToken('Jellyseerr'); settings.jellyfin.apiKey = apiKey;
settings.jellyfin.apiKey = apiKey;
} catch {
throw new Error(
"Failed to create Jellyfin API token from admin account. Please check your network configuration or edit your settings.json by adding an 'apiKey' field inside of the 'jellyfin' section to fix this issue."
);
}
} }
return settings; return settings;
}; };

View File

@@ -1,6 +1,7 @@
/* eslint-disable no-console */
import type { AllSettings } from '@server/lib/settings'; import type { AllSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import fs from 'fs/promises'; import fs from 'fs';
import path from 'path'; import path from 'path';
const migrationsDir = path.join(__dirname, 'migrations'); const migrationsDir = path.join(__dirname, 'migrations');
@@ -9,56 +10,19 @@ export const runMigrations = async (
settings: AllSettings, settings: AllSettings,
SETTINGS_PATH: string SETTINGS_PATH: string
): Promise<AllSettings> => { ): 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; let migrated = settings;
try { 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); const settingsBefore = JSON.stringify(migrated);
for (const migration of migrations) { for (const migration of migrations) {
try { migrated = await migration(migrated);
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) {
// we stop jellyseerr if the migration failed
logger.error(
`Error while running migration '${migration}': ${e.message}`,
{
label: 'Settings Migrator',
}
);
logger.error(
'A common cause for this error is a permission issue with your configuration folder, a network issue or a corrupted database.',
{
label: 'Settings Migrator',
}
);
process.exit();
}
} }
const settingsAfter = JSON.stringify(migrated); const settingsAfter = JSON.stringify(migrated);
@@ -66,33 +30,30 @@ export const runMigrations = async (
if (settingsBefore !== settingsAfter) { if (settingsBefore !== settingsAfter) {
// a migration occured // a migration occured
// we check that the new config will be saved // we check that the new config will be saved
await fs.writeFile( fs.writeFileSync(SETTINGS_PATH, JSON.stringify(migrated, undefined, ' '));
SETTINGS_PATH, const fileSaved = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8'));
JSON.stringify(migrated, undefined, ' ')
);
const fileSaved = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
if (JSON.stringify(fileSaved) !== settingsAfter) { if (JSON.stringify(fileSaved) !== settingsAfter) {
// something went wrong while saving file // something went wrong while saving file
throw new Error('Unable to save settings after migration.'); 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) { } catch (e) {
// we stop jellyseerr if the migration failed
logger.error( logger.error(
`Something went wrong while running settings migrations: ${e.message}`, `Something went wrong while running settings migrations: ${e.message}`,
{ { label: 'Settings Migrator' }
label: 'Settings Migrator',
}
); );
logger.error( // we stop jellyseerr if the migration failed
'A common cause for this issue is a permission error of your configuration folder.', console.log(
{ '===================================================================='
label: 'Settings Migrator', );
} console.log(
' SOMETHING WENT WRONG WHILE RUNNING SETTINGS MIGRATIONS '
);
console.log(
' Please check that your configuration folder is properly set up '
);
console.log(
'===================================================================='
); );
process.exit(); process.exit();
} }

View File

@@ -6,6 +6,7 @@ import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User'; import { User } from '@server/entity/User';
import { startJobs } from '@server/job/schedule'; import { startJobs } from '@server/job/schedule';
import ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions'; import { 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';
@@ -14,6 +15,7 @@ import { ApiError } from '@server/types/error';
import { getHostname } from '@server/utils/getHostname'; 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 net from 'net'; import net from 'net';
const authRoutes = Router(); const authRoutes = Router();
@@ -87,7 +89,7 @@ authRoutes.post('/plex', async (req, res, next) => {
}); });
settings.main.mediaServerType = MediaServerType.PLEX; settings.main.mediaServerType = MediaServerType.PLEX;
await settings.save(); settings.save();
startJobs(); startJobs();
await userRepository.save(user); await userRepository.save(user);
@@ -299,84 +301,64 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
where: { jellyfinUserId: account.User.Id }, where: { jellyfinUserId: account.User.Id },
}); });
const missingAdminUser = !user && !(await userRepository.count()); if (!user && !(await userRepository.count())) {
if (
missingAdminUser ||
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED
) {
// Check if user is admin on jellyfin // Check if user is admin on jellyfin
if (account.User.Policy.IsAdministrator === false) { if (account.User.Policy.IsAdministrator === false) {
throw new ApiError(403, ApiErrorCode.NotAdmin); throw new ApiError(403, ApiErrorCode.NotAdmin);
} }
if ( logger.info(
body.serverType !== MediaServerType.JELLYFIN && 'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr',
body.serverType !== MediaServerType.EMBY {
) { label: 'API',
throw new Error('select_server_type'); ip: req.ip,
}
settings.main.mediaServerType = body.serverType;
if (missingAdminUser) {
logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Jellyseerr',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
// User doesn't exist, and there are no users in the database, we'll create the user
// with admin permissions
user = new User({
id: 1,
email: body.email || account.User.Name,
jellyfinUsername: account.User.Name, jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`,
userType:
body.serverType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
});
await userRepository.save(user);
} else {
logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; editing admin user for Jellyseerr',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
// User alread exist but settings.json is not configured, we'll edit the admin user
user = await userRepository.findOne({
where: { id: 1 },
});
if (!user) {
throw new Error('Unable to find admin user to edit');
} }
user.email = body.email || account.User.Name; );
user.jellyfinUsername = account.User.Name;
user.jellyfinUserId = account.User.Id;
user.jellyfinDeviceId = deviceId;
user.jellyfinAuthToken = account.AccessToken;
user.permissions = Permission.ADMIN;
user.avatar = `/avatarproxy/${account.User.Id}`;
user.userType =
body.serverType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY;
await userRepository.save(user); // User doesn't exist, and there are no users in the database, we'll create the user
// with admin permissions
switch (body.serverType) {
case MediaServerType.EMBY:
settings.main.mediaServerType = MediaServerType.EMBY;
user = new User({
email: body.email || account.User.Name,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
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,
});
break;
case MediaServerType.JELLYFIN:
settings.main.mediaServerType = MediaServerType.JELLYFIN;
user = new User({
email: body.email || account.User.Name,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
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,
});
break;
default:
throw new Error('select_server_type');
} }
// Create an API key on Jellyfin from this admin user // Create an API key on Jellyfin from this admin user
@@ -396,8 +378,10 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
settings.jellyfin.urlBase = body.urlBase ?? ''; settings.jellyfin.urlBase = body.urlBase ?? '';
settings.jellyfin.useSsl = body.useSsl ?? false; settings.jellyfin.useSsl = body.useSsl ?? false;
settings.jellyfin.apiKey = apiKey; settings.jellyfin.apiKey = apiKey;
await settings.save(); settings.save();
startJobs(); startJobs();
await userRepository.save(user);
} }
// User already exists, let's update their information // User already exists, let's update their information
else if (account.User.Id === user?.jellyfinUserId) { else if (account.User.Id === user?.jellyfinUserId) {
@@ -417,7 +401,27 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUsername: account.User.Name, 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; user.jellyfinUsername = account.User.Name;
if (user.username === account.User.Name) { if (user.username === account.User.Name) {
@@ -455,7 +459,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUserId: account.User.Id, jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId, jellyfinDeviceId: deviceId,
permissions: settings.main.defaultPermissions, 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: userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN ? UserType.JELLYFIN

View File

@@ -1,39 +1,21 @@
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import ImageProxy from '@server/lib/imageproxy'; import ImageProxy from '@server/lib/imageproxy';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { getAppVersion } from '@server/utils/appVersion';
import { getHostname } from '@server/utils/getHostname'; import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
const router = Router(); const router = Router();
let _avatarImageProxy: ImageProxy | null = null; const avatarImageProxy = new ImageProxy('avatar', '');
async function initAvatarImageProxy() { // Proxy avatar images
if (!_avatarImageProxy) { router.get('/*', async (req, res) => {
const userRepository = getRepository(User); let imagePath = '';
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) => {
try { 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; const mediaServerType = getSettings().main.mediaServerType;
throw new Error( throw new Error(
`Provided URL is not ${ `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({ const imageData = await avatarImageProxy.getImage(imagePath);
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);
}
res.writeHead(200, { res.writeHead(200, {
'Content-Type': `image/${imageData.meta.extension}`, 'Content-Type': `image/${imageData.meta.extension}`,
@@ -78,6 +42,7 @@ router.get('/:jellyfinUserId', async (req, res) => {
res.end(imageData.imageBuffer); res.end(imageData.imageBuffer);
} catch (e) { } catch (e) {
logger.error('Failed to proxy avatar image', { logger.error('Failed to proxy avatar image', {
imagePath,
errorMessage: e.message, errorMessage: e.message,
}); });
} }

View File

@@ -2,13 +2,14 @@ import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist'; import { Blacklist } from '@server/entity/Blacklist';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import { NotFoundError } from '@server/entity/Watchlist';
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces'; import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express'; import { Router } from 'express';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import { EntityNotFoundError, QueryFailedError } from 'typeorm'; import { QueryFailedError } from 'typeorm';
import { z } from 'zod'; import { z } from 'zod';
const blacklistRoutes = Router(); const blacklistRoutes = Router();
@@ -70,32 +71,6 @@ blacklistRoutes.get(
} }
); );
blacklistRoutes.get(
'/:id',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
type: 'or',
}),
async (req, res, next) => {
try {
const blacklisteRepository = getRepository(Blacklist);
const blacklistItem = await blacklisteRepository.findOneOrFail({
where: { tmdbId: Number(req.params.id) },
});
return res.status(200).send(blacklistItem);
} catch (e) {
if (e instanceof EntityNotFoundError) {
return next({
status: 401,
message: e.message,
});
}
return next({ status: 500, message: e.message });
}
}
);
blacklistRoutes.post( blacklistRoutes.post(
'/', '/',
isAuthenticated([Permission.MANAGE_BLACKLIST], { isAuthenticated([Permission.MANAGE_BLACKLIST], {
@@ -159,7 +134,7 @@ blacklistRoutes.delete(
return res.status(204).send(); return res.status(204).send();
} catch (e) { } catch (e) {
if (e instanceof EntityNotFoundError) { if (e instanceof NotFoundError) {
return next({ return next({
status: 401, status: 401,
message: e.message, message: e.message,

View File

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

View File

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

View File

@@ -32,6 +32,7 @@ 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';
import gravatarUrl from 'gravatar-url';
import { escapeRegExp, merge, omit, set, sortBy } from 'lodash'; import { escapeRegExp, merge, omit, set, sortBy } from 'lodash';
import { rescheduleJob } from 'node-schedule'; import { rescheduleJob } from 'node-schedule';
import path from 'path'; import path from 'path';
@@ -69,19 +70,19 @@ settingsRoutes.get('/main', (req, res, next) => {
res.status(200).json(filteredMainSettings(req.user, settings.main)); res.status(200).json(filteredMainSettings(req.user, settings.main));
}); });
settingsRoutes.post('/main', async (req, res) => { settingsRoutes.post('/main', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.main = merge(settings.main, req.body); settings.main = merge(settings.main, req.body);
await settings.save(); settings.save();
return res.status(200).json(settings.main); 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 settings = getSettings();
const main = await settings.regenerateApiKey(); const main = settings.regenerateApiKey();
if (!req.user) { if (!req.user) {
return next({ status: 500, message: 'User missing from request.' }); 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.machineId = result.MediaContainer.machineIdentifier;
settings.plex.name = result.MediaContainer.friendlyName; settings.plex.name = result.MediaContainer.friendlyName;
await settings.save(); settings.save();
} catch (e) { } catch (e) {
logger.error('Something went wrong testing Plex connection', { logger.error('Something went wrong testing Plex connection', {
label: 'API', label: 'API',
@@ -231,7 +232,7 @@ settingsRoutes.get('/plex/library', async (req, res) => {
...library, ...library,
enabled: enabledLibraries.includes(library.id), enabled: enabledLibraries.includes(library.id),
})); }));
await settings.save(); settings.save();
return res.status(200).json(settings.plex.libraries); 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); Object.assign(settings.jellyfin, req.body);
settings.jellyfin.serverId = result.Id; settings.jellyfin.serverId = result.Id;
settings.jellyfin.name = result.ServerName; settings.jellyfin.name = result.ServerName;
await settings.save(); settings.save();
} catch (e) { } catch (e) {
if (e instanceof ApiError) { if (e instanceof ApiError) {
logger.error('Something went wrong testing Jellyfin connection', { logger.error('Something went wrong testing Jellyfin connection', {
@@ -370,7 +371,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
...library, ...library,
enabled: enabledLibraries.includes(library.id), enabled: enabledLibraries.includes(library.id),
})); }));
await settings.save(); settings.save();
return res.status(200).json(settings.jellyfin.libraries); 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) => ({ const users = resp.users.map((user) => ({
username: user.Name, username: user.Name,
id: user.Id, 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, email: user.Name,
})); }));
@@ -434,7 +437,7 @@ settingsRoutes.post('/tautulli', async (req, res, next) => {
throw new Error('Tautulli version not supported'); throw new Error('Tautulli version not supported');
} }
await settings.save(); settings.save();
} catch (e) { } catch (e) {
logger.error('Something went wrong testing Tautulli connection', { logger.error('Something went wrong testing Tautulli connection', {
label: 'API', label: 'API',
@@ -695,7 +698,7 @@ settingsRoutes.post<{ jobId: JobId }>(
settingsRoutes.post<{ jobId: JobId }>( settingsRoutes.post<{ jobId: JobId }>(
'/jobs/:jobId/schedule', '/jobs/:jobId/schedule',
async (req, res, next) => { (req, res, next) => {
const scheduledJob = scheduledJobs.find( const scheduledJob = scheduledJobs.find(
(job) => job.id === req.params.jobId (job) => job.id === req.params.jobId
); );
@@ -709,7 +712,7 @@ settingsRoutes.post<{ jobId: JobId }>(
if (result) { if (result) {
settings.jobs[scheduledJob.id].schedule = req.body.schedule; settings.jobs[scheduledJob.id].schedule = req.body.schedule;
await settings.save(); settings.save();
scheduledJob.cronSchedule = req.body.schedule; scheduledJob.cronSchedule = req.body.schedule;
@@ -766,11 +769,11 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
settingsRoutes.post( settingsRoutes.post(
'/initialize', '/initialize',
isAuthenticated(Permission.ADMIN), isAuthenticated(Permission.ADMIN),
async (_req, res) => { (_req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.public.initialized = true; settings.public.initialized = true;
await settings.save(); settings.save();
return res.status(200).json(settings.public); 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); res.status(200).json(settings.notifications.agents.discord);
}); });
notificationRoutes.post('/discord', async (req, res) => { notificationRoutes.post('/discord', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.discord = req.body; settings.notifications.agents.discord = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.discord); 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); res.status(200).json(settings.notifications.agents.slack);
}); });
notificationRoutes.post('/slack', async (req, res) => { notificationRoutes.post('/slack', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.slack = req.body; settings.notifications.agents.slack = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.slack); 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); res.status(200).json(settings.notifications.agents.telegram);
}); });
notificationRoutes.post('/telegram', async (req, res) => { notificationRoutes.post('/telegram', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.telegram = req.body; settings.notifications.agents.telegram = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.telegram); 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); res.status(200).json(settings.notifications.agents.pushbullet);
}); });
notificationRoutes.post('/pushbullet', async (req, res) => { notificationRoutes.post('/pushbullet', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.pushbullet = req.body; settings.notifications.agents.pushbullet = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.pushbullet); 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); res.status(200).json(settings.notifications.agents.pushover);
}); });
notificationRoutes.post('/pushover', async (req, res) => { notificationRoutes.post('/pushover', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.pushover = req.body; settings.notifications.agents.pushover = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.pushover); 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); res.status(200).json(settings.notifications.agents.email);
}); });
notificationRoutes.post('/email', async (req, res) => { notificationRoutes.post('/email', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.email = req.body; settings.notifications.agents.email = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.email); 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); res.status(200).json(settings.notifications.agents.webpush);
}); });
notificationRoutes.post('/webpush', async (req, res) => { notificationRoutes.post('/webpush', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.webpush = req.body; settings.notifications.agents.webpush = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.webpush); res.status(200).json(settings.notifications.agents.webpush);
}); });
@@ -284,7 +284,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
res.status(200).json(response); res.status(200).json(response);
}); });
notificationRoutes.post('/webhook', async (req, res, next) => { notificationRoutes.post('/webhook', (req, res, next) => {
const settings = getSettings(); const settings = getSettings();
try { try {
JSON.parse(req.body.options.jsonPayload); JSON.parse(req.body.options.jsonPayload);
@@ -300,7 +300,7 @@ notificationRoutes.post('/webhook', async (req, res, next) => {
authHeader: req.body.options.authHeader, authHeader: req.body.options.authHeader,
}, },
}; };
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.webhook); res.status(200).json(settings.notifications.agents.webhook);
} catch (e) { } catch (e) {
@@ -351,11 +351,11 @@ notificationRoutes.get('/lunasea', (_req, res) => {
res.status(200).json(settings.notifications.agents.lunasea); res.status(200).json(settings.notifications.agents.lunasea);
}); });
notificationRoutes.post('/lunasea', async (req, res) => { notificationRoutes.post('/lunasea', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.lunasea = req.body; settings.notifications.agents.lunasea = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.lunasea); 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); res.status(200).json(settings.notifications.agents.gotify);
}); });
notificationRoutes.post('/gotify', async (req, res) => { notificationRoutes.post('/gotify', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.gotify = req.body; settings.notifications.agents.gotify = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.gotify); res.status(200).json(settings.notifications.agents.gotify);
}); });

View File

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

View File

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

View File

@@ -539,7 +539,12 @@ router.post(
).toString('base64'), ).toString('base64'),
email: jellyfinUser?.Name, email: jellyfinUser?.Name,
permissions: settings.main.defaultPermissions, 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: userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN ? UserType.JELLYFIN

View File

@@ -1,4 +1,4 @@
import { accessSync, existsSync } from 'fs'; import { existsSync } from 'fs';
import path from 'path'; import path from 'path';
const CONFIG_PATH = process.env.CONFIG_DIRECTORY const CONFIG_PATH = process.env.CONFIG_DIRECTORY
@@ -14,12 +14,3 @@ export const appDataStatus = (): boolean => {
export const appDataPath = (): string => { export const appDataPath = (): string => {
return CONFIG_PATH; 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 ( return (
this.settings.csrfProtection !== settings.csrfProtection || this.settings.csrfProtection !== settings.csrfProtection ||
this.settings.trustProxy !== settings.trustProxy || this.settings.trustProxy !== settings.trustProxy ||
this.settings.proxy.enabled !== settings.proxy.enabled this.settings.httpProxy !== settings.httpProxy
); );
} }
} }

View File

@@ -1,6 +1,5 @@
import Badge from '@app/components/Common/Badge'; import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Tooltip from '@app/components/Common/Tooltip'; import Tooltip from '@app/components/Common/Tooltip';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
@@ -11,7 +10,6 @@ import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('component.BlacklistBlock', { const messages = defineMessages('component.BlacklistBlock', {
blacklistedby: 'Blacklisted By', blacklistedby: 'Blacklisted By',
@@ -19,13 +17,13 @@ const messages = defineMessages('component.BlacklistBlock', {
}); });
interface BlacklistBlockProps { interface BlacklistBlockProps {
tmdbId: number; blacklistItem: Blacklist;
onUpdate?: () => void; onUpdate?: () => void;
onDelete?: () => void; onDelete?: () => void;
} }
const BlacklistBlock = ({ const BlacklistBlock = ({
tmdbId, blacklistItem,
onUpdate, onUpdate,
onDelete, onDelete,
}: BlacklistBlockProps) => { }: BlacklistBlockProps) => {
@@ -33,7 +31,6 @@ const BlacklistBlock = ({
const intl = useIntl(); const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const { addToast } = useToasts(); const { addToast } = useToasts();
const { data } = useSWR<Blacklist>(`/api/v1/blacklist/${tmdbId}`);
const removeFromBlacklist = async (tmdbId: number, title?: string) => { const removeFromBlacklist = async (tmdbId: number, title?: string) => {
setIsUpdating(true); setIsUpdating(true);
@@ -65,14 +62,6 @@ const BlacklistBlock = ({
setIsUpdating(false); setIsUpdating(false);
}; };
if (!data) {
return (
<>
<LoadingSpinner />
</>
);
}
return ( return (
<div className="px-4 py-3 text-gray-300"> <div className="px-4 py-3 text-gray-300">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -84,13 +73,13 @@ const BlacklistBlock = ({
<span className="w-40 truncate md:w-auto"> <span className="w-40 truncate md:w-auto">
<Link <Link
href={ href={
data.user.id === user?.id blacklistItem.user.id === user?.id
? '/profile' ? '/profile'
: `/users/${data.user.id}` : `/users/${blacklistItem.user.id}`
} }
> >
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"> <span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{data.user.displayName} {blacklistItem.user.displayName}
</span> </span>
</Link> </Link>
</span> </span>
@@ -102,7 +91,9 @@ const BlacklistBlock = ({
> >
<Button <Button
buttonType="danger" buttonType="danger"
onClick={() => removeFromBlacklist(data.tmdbId, data.title)} onClick={() =>
removeFromBlacklist(blacklistItem.tmdbId, blacklistItem.title)
}
disabled={isUpdating} disabled={isUpdating}
> >
<TrashIcon className="icon-sm" /> <TrashIcon className="icon-sm" />
@@ -123,7 +114,7 @@ const BlacklistBlock = ({
<CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" /> <CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
</Tooltip> </Tooltip>
<span> <span>
{intl.formatDate(data.createdAt, { {intl.formatDate(blacklistItem.createdAt, {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',

View File

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

View File

@@ -292,7 +292,7 @@ const ManageSlideOver = ({
</h3> </h3>
<div className="overflow-hidden rounded-md border border-gray-700 shadow"> <div className="overflow-hidden rounded-md border border-gray-700 shadow">
<BlacklistBlock <BlacklistBlock
tmdbId={data.mediaInfo.tmdbId} blacklistItem={data.mediaInfo.blacklist}
onUpdate={() => revalidate()} onUpdate={() => revalidate()}
onDelete={() => onClose()} onDelete={() => onClose()}
/> />

View File

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

View File

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