Compare commits

..

2 Commits

Author SHA1 Message Date
Gauthier
329527bd03 fix: update avatar on new login 2024-10-24 11:38:06 +02:00
Gauthier
269fd69dff fix: cache Jellyfin/Emby avatars from API
Previously, avatars were cached using image links from Jellyfin/Emby. Now, avatar images are
obtained directly from the API to avoid some configuration bugs.
2024-10-22 00:28:31 +02:00
40 changed files with 774 additions and 1225 deletions

View File

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

2
.gitattributes vendored
View File

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

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

@@ -52,7 +52,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
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.

View File

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

View File

@@ -190,7 +190,7 @@ Caddy will automatically obtain and renew SSL certificates for your domain.
## 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
labels:

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

@@ -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/).
#### Installation:
Define the `jellyseerr` service in your `compose.yaml` as follows:
Define the `jellyseerr` service in your `docker-compose.yml` as follows:
```yaml
---
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:
```bash
docker compose up -d
docker-compose up -d
```
#### Updating:
Pull the latest image:
```bash
docker compose pull jellyseerr
docker-compose pull jellyseerr
```
Then, restart all services defined in the Compose file:
```bash
docker compose up -d
docker-compose up -d
```
:::tip
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:
type: string
example: /app/config
appDataPermissions:
type: boolean
example: true
/settings/main:
get:
summary: Get main settings

View File

@@ -43,7 +43,6 @@
"@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.30",
"ace-builds": "1.15.2",
"axios": "^1.7.7",
"bcrypt": "5.1.0",
"bowser": "2.11.0",
"connect-typeorm": "1.1.4",
@@ -94,8 +93,7 @@
"sqlite3": "5.1.4",
"swagger-ui-express": "4.6.2",
"swr": "2.2.5",
"typeorm": "0.3.11",
"undici": "^6.20.1",
"typeorm": "0.3.12",
"web-push": "3.5.0",
"winston": "3.8.2",
"winston-daily-rotate-file": "4.7.1",

105
pnpm-lock.yaml generated
View File

@@ -41,9 +41,6 @@ importers:
ace-builds:
specifier: 1.15.2
version: 1.15.2
axios:
specifier: ^1.7.7
version: 1.7.7
bcrypt:
specifier: 5.1.0
version: 5.1.0(encoding@0.1.13)
@@ -52,7 +49,7 @@ importers:
version: 2.11.0
connect-typeorm:
specifier: 1.1.4
version: 1.1.4(typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)))
version: 1.1.4(typeorm@0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)))
cookie-parser:
specifier: 1.4.6
version: 1.4.6
@@ -195,11 +192,8 @@ importers:
specifier: 2.2.5
version: 2.2.5(react@18.3.1)
typeorm:
specifier: 0.3.11
version: 0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
undici:
specifier: ^6.20.1
version: 6.20.1
specifier: 0.3.12
version: 0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
web-push:
specifier: 3.5.0
version: 3.5.0
@@ -3405,9 +3399,6 @@ packages:
resolution: {integrity: sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==}
engines: {node: '>=4'}
axios@1.7.7:
resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==}
axobject-query@3.1.1:
resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==}
@@ -4273,6 +4264,10 @@ packages:
resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==}
engines: {node: '>=0.11'}
date-fns@2.30.0:
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
engines: {node: '>=0.11'}
dateformat@3.0.3:
resolution: {integrity: sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==}
@@ -5028,15 +5023,6 @@ packages:
fn.name@1.1.0:
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
follow-redirects@1.15.9:
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
for-each@0.3.3:
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
@@ -5051,10 +5037,6 @@ packages:
resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==}
engines: {node: '>= 0.12'}
form-data@4.0.1:
resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==}
engines: {node: '>= 6'}
formik@2.4.6:
resolution: {integrity: sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==}
peerDependencies:
@@ -5407,8 +5389,8 @@ packages:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
https-proxy-agent@7.0.5:
resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==}
https-proxy-agent@7.0.4:
resolution: {integrity: sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==}
engines: {node: '>= 14'}
human-signals@1.1.1:
@@ -6572,6 +6554,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
mkdirp@2.1.6:
resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==}
engines: {node: '>=10'}
hasBin: true
modify-values@1.0.1:
resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==}
engines: {node: '>=0.10.0'}
@@ -7403,9 +7390,6 @@ packages:
proxy-from-env@1.0.0:
resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
psl@1.9.0:
resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==}
@@ -7746,6 +7730,9 @@ packages:
reflect-metadata@0.1.13:
resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==}
reflect-metadata@0.1.14:
resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==}
reflect.getprototypeof@1.0.6:
resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==}
engines: {node: '>= 0.4'}
@@ -8683,8 +8670,8 @@ packages:
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typeorm@0.3.11:
resolution: {integrity: sha512-pzdOyWbVuz/z8Ww6gqvBW4nylsM0KLdUCDExr2gR20/x1khGSVxQkjNV/3YqliG90jrWzrknYbYscpk8yxFJVg==}
typeorm@0.3.12:
resolution: {integrity: sha512-sYSxBmCf1nJLLTcYtwqZ+lQIRtLPyUoO93rHTOKk9vJCyT4UfRtU7oRsJvfvKP3nnZTD1hzz2SEy2zwPEN6OyA==}
engines: {node: '>= 12.9.0'}
hasBin: true
peerDependencies:
@@ -8695,7 +8682,7 @@ packages:
ioredis: ^5.0.4
mongodb: ^3.6.0
mssql: ^7.3.0
mysql2: ^2.2.5
mysql2: ^2.2.5 || ^3.0.1
oracledb: ^5.1.0
pg: ^8.5.1
pg-native: ^3.0.0
@@ -8781,10 +8768,6 @@ packages:
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici@6.20.1:
resolution: {integrity: sha512-AjQF1QsmqfJys+LXfGTNum+qw4S88CojRInG/6t31W/1fk6G59s92bnAvGz5Cmur+kQv2SURXEvvudLmbrE8QA==}
engines: {node: '>=18.17'}
unicode-canonical-property-names-ecmascript@2.0.0:
resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==}
engines: {node: '>=4'}
@@ -12327,7 +12310,7 @@ snapshots:
fs-extra: 11.2.0
globby: 11.1.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.5
https-proxy-agent: 7.0.4
issue-parser: 6.0.0
lodash: 4.17.21
mime: 3.0.0
@@ -13285,14 +13268,6 @@ snapshots:
axe-core@4.9.1: {}
axios@1.7.7:
dependencies:
follow-redirects: 1.15.9
form-data: 4.0.1
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
axobject-query@3.1.1:
dependencies:
deep-equal: 2.2.3
@@ -13849,13 +13824,13 @@ snapshots:
ini: 1.3.8
proto-list: 1.2.4
connect-typeorm@1.1.4(typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))):
connect-typeorm@1.1.4(typeorm@0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))):
dependencies:
'@types/debug': 0.0.31
'@types/express-session': 1.17.6
debug: 4.3.5(supports-color@8.1.1)
express-session: 1.18.0
typeorm: 0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
typeorm: 0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
transitivePeerDependencies:
- supports-color
@@ -14206,6 +14181,10 @@ snapshots:
date-fns@2.29.3: {}
date-fns@2.30.0:
dependencies:
'@babel/runtime': 7.24.7
dateformat@3.0.3: {}
dayjs@1.11.11: {}
@@ -15308,8 +15287,6 @@ snapshots:
fn.name@1.1.0: {}
follow-redirects@1.15.9: {}
for-each@0.3.3:
dependencies:
is-callable: 1.2.7
@@ -15327,12 +15304,6 @@ snapshots:
combined-stream: 1.0.8
mime-types: 2.1.35
form-data@4.0.1:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
mime-types: 2.1.35
formik@2.4.6(react@18.3.1):
dependencies:
'@types/hoist-non-react-statics': 3.3.5
@@ -15768,7 +15739,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.5:
https-proxy-agent@7.0.4:
dependencies:
agent-base: 7.1.1
debug: 4.3.5(supports-color@8.1.1)
@@ -17178,6 +17149,8 @@ snapshots:
mkdirp@1.0.4: {}
mkdirp@2.1.6: {}
modify-values@1.0.1: {}
moment@2.30.1: {}
@@ -17880,8 +17853,6 @@ snapshots:
proxy-from-env@1.0.0: {}
proxy-from-env@1.1.0: {}
psl@1.9.0: {}
pstree.remy@1.1.8: {}
@@ -18401,6 +18372,8 @@ snapshots:
reflect-metadata@0.1.13: {}
reflect-metadata@0.1.14: {}
reflect.getprototypeof@1.0.6:
dependencies:
call-bind: 1.0.7
@@ -19458,23 +19431,23 @@ snapshots:
typedarray@0.0.6: {}
typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)):
typeorm@0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)):
dependencies:
'@sqltools/formatter': 1.2.5
app-root-path: 3.1.0
buffer: 6.0.3
chalk: 4.1.2
cli-highlight: 2.1.11
date-fns: 2.29.3
date-fns: 2.30.0
debug: 4.3.5(supports-color@8.1.1)
dotenv: 16.4.5
glob: 7.2.3
glob: 8.1.0
js-yaml: 4.1.0
mkdirp: 1.0.4
reflect-metadata: 0.1.13
mkdirp: 2.1.6
reflect-metadata: 0.1.14
sha.js: 2.4.11
tslib: 2.6.3
uuid: 8.3.2
uuid: 9.0.1
xml2js: 0.4.23
yargs: 17.7.2
optionalDependencies:
@@ -19513,8 +19486,6 @@ snapshots:
undici-types@5.26.5: {}
undici@6.20.1: {}
unicode-canonical-property-names-ecmascript@2.0.0: {}
unicode-emoji-utils@1.2.0:

View File

@@ -1,6 +1,5 @@
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import axios from 'axios';
// import rateLimit from 'axios-rate-limit';
import type { RateLimitOptions } from '@server/utils/rateLimit';
import rateLimit from '@server/utils/rateLimit';
import type NodeCache from 'node-cache';
// 5 minute default TTL (in seconds)
@@ -12,71 +11,87 @@ const DEFAULT_ROLLING_BUFFER = 10000;
interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
rateLimit?: {
maxRPS: number;
maxRequests: number;
};
rateLimit?: RateLimitOptions;
}
class ExternalAPI {
protected axios: AxiosInstance;
protected fetch: typeof fetch;
protected params: Record<string, string>;
protected defaultHeaders: { [key: string]: string };
private baseUrl: string;
private cache?: NodeCache;
constructor(
baseUrl: string,
params: Record<string, unknown>,
params: Record<string, string> = {},
options: ExternalAPIOptions = {}
) {
this.axios = axios.create({
baseURL: baseUrl,
params,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
},
});
// if (options.rateLimit) {
// this.axios = rateLimit(this.axios, {
// maxRequests: options.rateLimit.maxRequests,
// maxRPS: options.rateLimit.maxRPS,
// });
// }
if (options.rateLimit) {
this.fetch = rateLimit(fetch, options.rateLimit);
} else {
this.fetch = fetch;
}
this.baseUrl = baseUrl;
this.params = params;
this.defaultHeaders = {
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
};
this.cache = options.nodeCache;
}
protected async get<T>(
endpoint: string,
config?: AxiosRequestConfig,
ttl?: number
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, config?.params);
const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const response = await this.axios.get<T>(endpoint, config);
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
}
return response.data;
return data;
}
protected async post<T>(
endpoint: string,
data?: Record<string, unknown>,
config?: AxiosRequestConfig,
ttl?: number
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
config: config?.params,
config: { ...this.params, ...params },
data,
});
const cachedItem = this.cache?.get<T>(cacheKey);
@@ -84,23 +99,43 @@ class ExternalAPI {
return cachedItem;
}
const response = await this.axios.post<T>(endpoint, data, config);
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'POST',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
body: data ? JSON.stringify(data) : undefined,
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const resData = await this.getDataFromResponse(response);
if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
}
return response.data;
return resData;
}
protected async put<T>(
endpoint: string,
data: Record<string, unknown>,
config?: AxiosRequestConfig,
ttl?: number
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
config: config?.params,
config: { ...this.params, ...params },
data,
});
const cachedItem = this.cache?.get<T>(cacheKey);
@@ -108,41 +143,73 @@ class ExternalAPI {
return cachedItem;
}
const response = await this.axios.put<T>(endpoint, data, config);
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'PUT',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
body: JSON.stringify(data),
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const resData = await this.getDataFromResponse(response);
if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
}
return response.data;
return resData;
}
protected async delete<T>(
endpoint: string,
config?: AxiosRequestConfig,
ttl?: number
params?: Record<string, string>,
config?: RequestInit
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, config?.params);
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'DELETE',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
const response = await this.axios.delete<T>(endpoint, config);
if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}
return response.data;
return data;
}
protected async getRolling<T>(
endpoint: string,
config?: AxiosRequestConfig,
ttl?: number
params?: Record<string, string>,
ttl?: number,
config?: RequestInit,
overwriteBaseUrl?: string
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, config?.params);
const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
@@ -153,20 +220,78 @@ class ExternalAPI {
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
Date.now() - DEFAULT_ROLLING_BUFFER
) {
this.axios.get<T>(endpoint, config).then((response) => {
this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
}).then(async (response) => {
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${
text ? ': ' + text : ''
}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL);
});
}
return cachedItem;
}
const response = await this.axios.get<T>(endpoint, config);
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
const response = await this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
}
return response.data;
return data;
}
private formatUrl(
endpoint: string,
params?: Record<string, string>,
overwriteBaseUrl?: string
): string {
const baseUrl = overwriteBaseUrl || this.baseUrl;
const href =
baseUrl +
(baseUrl.endsWith('/') ? '' : '/') +
(endpoint.startsWith('/') ? endpoint.slice(1) : endpoint);
const searchParams = new URLSearchParams({
...this.params,
...params,
});
return (
href +
(searchParams.toString().length
? '?' + searchParams.toString()
: searchParams.toString())
);
}
private serializeCacheKey(
@@ -179,6 +304,29 @@ class ExternalAPI {
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
}
private async getDataFromResponse(response: Response) {
const contentType = response.headers.get('Content-Type');
if (contentType?.includes('application/json')) {
return await response.json();
} else if (
contentType?.includes('application/xml') ||
contentType?.includes('text/html') ||
contentType?.includes('text/plain')
) {
return await response.text();
} else {
try {
return await response.json();
} catch {
try {
return await response.blob();
} catch {
return null;
}
}
}
}
}
export default ExternalAPI;

View File

@@ -1,6 +1,6 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache';
import logger from '@server/logger';
import ExternalAPI from './externalapi';
interface GitHubRelease {
url: string;
@@ -67,10 +67,6 @@ class GithubAPI extends ExternalAPI {
'https://api.github.com',
{},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('github').data,
}
);
@@ -85,9 +81,7 @@ class GithubAPI extends ExternalAPI {
const data = await this.get<GitHubRelease[]>(
'/repos/fallenbagel/jellyseerr/releases',
{
params: {
per_page: take,
},
per_page: take.toString(),
}
);
@@ -112,10 +106,8 @@ class GithubAPI extends ExternalAPI {
const data = await this.get<GithubCommit[]>(
'/repos/fallenbagel/jellyseerr/commits',
{
params: {
per_page: take,
branch,
},
per_page: take.toString(),
branch,
}
);

View File

@@ -129,6 +129,8 @@ class JellyfinAPI extends ExternalAPI {
Username,
Pw: Password,
},
{},
undefined,
{ headers }
);
};
@@ -291,15 +293,13 @@ class JellyfinAPI extends ExternalAPI {
const libraryItemsResponse = await this.get<any>(
`/Users/${this.userId}/Items`,
{
params: {
SortBy: 'SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Series,Movie,Others',
Recursive: 'true',
StartIndex: '0',
ParentId: id,
collapseBoxSetItems: 'false',
},
SortBy: 'SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Series,Movie,Others',
Recursive: 'true',
StartIndex: '0',
ParentId: id,
collapseBoxSetItems: 'false',
}
);
@@ -321,10 +321,8 @@ class JellyfinAPI extends ExternalAPI {
const itemResponse = await this.get<any>(
`/Users/${this.userId}/Items/Latest`,
{
params: {
Limit: '12',
ParentId: id,
},
Limit: '12',
ParentId: id,
}
);
@@ -386,9 +384,7 @@ class JellyfinAPI extends ExternalAPI {
const episodeResponse = await this.get<any>(
`/Shows/${seriesID}/Episodes`,
{
params: {
seasonId: seasonID,
},
seasonId: seasonID,
}
);
@@ -414,7 +410,7 @@ class JellyfinAPI extends ExternalAPI {
).AccessToken;
} catch (e) {
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' }
);

View File

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

View File

@@ -1,9 +1,9 @@
import ExternalAPI from '@server/api/externalapi';
import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import xml2js from 'xml2js';
import ExternalAPI from './externalapi';
interface PlexAccountResponse {
user: PlexUser;
@@ -137,8 +137,6 @@ class PlexTvAPI extends ExternalAPI {
{
headers: {
'X-Plex-Token': authToken,
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('plextv').data,
}
@@ -149,15 +147,11 @@ class PlexTvAPI extends ExternalAPI {
public async getDevices(): Promise<PlexDevice[]> {
try {
const devicesResp = await this.axios.get(
'/api/resources?includeHttps=1',
{
transformResponse: [],
responseType: 'text',
}
);
const devicesResp = await this.get('/api/resources', {
includeHttps: '1',
});
const parsedXml = await xml2js.parseStringPromise(
devicesResp.data as DeviceResponse
devicesResp as DeviceResponse
);
return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({
name: pxml.$.name,
@@ -205,11 +199,11 @@ class PlexTvAPI extends ExternalAPI {
public async getUser(): Promise<PlexUser> {
try {
const account = await this.axios.get<PlexAccountResponse>(
const account = await this.get<PlexAccountResponse>(
'/users/account.json'
);
return account.data.user;
return account.user;
} catch (e) {
logger.error(
`Something went wrong while getting the account from plex.tv: ${e.message}`,
@@ -249,13 +243,10 @@ class PlexTvAPI extends ExternalAPI {
}
public async getUsers(): Promise<UsersResponse> {
const response = await this.axios.get('/api/users', {
transformResponse: [],
responseType: 'text',
});
const data = await this.get('/api/users');
const parsedXml = (await xml2js.parseStringPromise(
response.data
data as string
)) as UsersResponse;
return parsedXml;
}
@@ -270,49 +261,49 @@ class PlexTvAPI extends ExternalAPI {
items: PlexWatchlistItem[];
}> {
try {
const response = await this.axios.get<WatchlistResponse>(
'/library/sections/watchlist/all',
const params = new URLSearchParams({
'X-Plex-Container-Start': offset.toString(),
'X-Plex-Container-Size': size.toString(),
});
const response = await this.fetch(
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
{
params: {
'X-Plex-Container-Start': offset,
'X-Plex-Container-Size': size,
},
baseURL: 'https://metadata.provider.plex.tv',
headers: this.defaultHeaders,
}
);
const data = (await response.json()) as WatchlistResponse;
const watchlistDetails = await Promise.all(
(response.data.MediaContainer.Metadata ?? []).map(
async (watchlistItem) => {
const detailedResponse = await this.getRolling<MetadataResponse>(
`/library/metadata/${watchlistItem.ratingKey}`,
{
baseURL: 'https://metadata.provider.plex.tv',
}
);
(data.MediaContainer.Metadata ?? []).map(async (watchlistItem) => {
const detailedResponse = await this.getRolling<MetadataResponse>(
`/library/metadata/${watchlistItem.ratingKey}`,
{},
undefined,
{},
'https://metadata.provider.plex.tv'
);
const metadata = detailedResponse.MediaContainer.Metadata[0];
const metadata = detailedResponse.MediaContainer.Metadata[0];
const tmdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tmdb')
);
const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb')
);
const tmdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tmdb')
);
const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb')
);
return {
ratingKey: metadata.ratingKey,
// This should always be set? But I guess it also cannot be?
// We will filter out the 0's afterwards
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1])
: undefined,
title: metadata.title,
type: metadata.type,
};
}
)
return {
ratingKey: metadata.ratingKey,
// This should always be set? But I guess it also cannot be?
// We will filter out the 0's afterwards
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1])
: undefined,
title: metadata.title,
type: metadata.type,
};
})
);
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
@@ -320,7 +311,7 @@ class PlexTvAPI extends ExternalAPI {
return {
offset,
size,
totalSize: response.data.MediaContainer.totalSize,
totalSize: data.MediaContainer.totalSize,
items: filteredList,
};
} catch (e) {

View File

@@ -1,4 +1,4 @@
import ExternalAPI from './externalapi';
import ExternalAPI from '@server/api/externalapi';
interface PushoverSoundsResponse {
sounds: {
@@ -26,24 +26,13 @@ export const mapSounds = (sounds: {
class PushoverAPI extends ExternalAPI {
constructor() {
super(
'https://api.pushover.net/1',
{},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
}
);
super('https://api.pushover.net/1');
}
public async getSounds(appToken: string): Promise<PushoverSound[]> {
try {
const data = await this.get<PushoverSoundsResponse>('/sounds.json', {
params: {
token: appToken,
},
token: appToken,
});
return mapSounds(data.sounds);

View File

@@ -160,9 +160,7 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
const data = await this.get<QueueResponse<QueueItemAppendT>>(
`/queue`,
{
params: {
includeEpisode: 'true',
},
includeEpisode: 'true',
},
0
);

View File

@@ -58,9 +58,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
try {
const data = await this.get<RadarrMovie[]>('/movie/lookup', {
params: {
term: `tmdb:${id}`,
},
term: `tmdb:${id}`,
});
if (!data[0]) {
@@ -224,10 +222,8 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
try {
const { id, title } = await this.getMovieByTmdbId(movieId);
await this.delete(`/movie/${id}`, {
params: {
deleteFiles: 'true',
addImportExclusion: 'false',
},
deleteFiles: 'true',
addImportExclusion: 'false',
});
logger.info(`[Radarr] Removed movie ${title}`);
} catch (e) {

View File

@@ -138,9 +138,7 @@ class SonarrAPI extends ServarrBase<{
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
try {
const data = await this.get<SonarrSeries[]>('/series/lookup', {
params: {
term: title,
},
term: title,
});
if (!data[0]) {
@@ -161,9 +159,7 @@ class SonarrAPI extends ServarrBase<{
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
try {
const data = await this.get<SonarrSeries[]>('/series/lookup', {
params: {
term: `tvdb:${id}`,
},
term: `tvdb:${id}`,
});
if (!data[0]) {
@@ -349,10 +345,8 @@ class SonarrAPI extends ServarrBase<{
try {
const { id, title } = await this.getSeriesByTvdbId(serieId);
await this.delete(`/series/${id}`, {
params: {
deleteFiles: 'true',
addImportExclusion: 'false',
},
deleteFiles: 'true',
addImportExclusion: 'false',
});
logger.info(`[Radarr] Removed serie ${title}`);
} catch (e) {

View File

@@ -1,8 +1,7 @@
import ExternalAPI from '@server/api/externalapi';
import type { User } from '@server/entity/User';
import type { TautulliSettings } from '@server/lib/settings';
import logger from '@server/logger';
import type { AxiosInstance } from 'axios';
import axios from 'axios';
import { uniqWith } from 'lodash';
export interface TautulliHistoryRecord {
@@ -113,25 +112,25 @@ interface TautulliInfoResponse {
};
}
class TautulliAPI {
private axios: AxiosInstance;
class TautulliAPI extends ExternalAPI {
constructor(settings: TautulliSettings) {
this.axios = axios.create({
baseURL: `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
super(
`${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
settings.port
}${settings.urlBase ?? ''}`,
params: { apikey: settings.apiKey },
});
{
apikey: settings.apiKey || '',
}
);
}
public async getInfo(): Promise<TautulliInfo> {
try {
return (
await this.axios.get<TautulliInfoResponse>('/api/v2', {
params: { cmd: 'get_tautulli_info' },
await this.get<TautulliInfoResponse>('/api/v2', {
cmd: 'get_tautulli_info',
})
).data.response.data;
).response.data;
} catch (e) {
logger.error('Something went wrong fetching Tautulli server info', {
label: 'Tautulli API',
@@ -148,14 +147,12 @@ class TautulliAPI {
): Promise<TautulliWatchStats[]> {
try {
return (
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
params: {
cmd: 'get_item_watch_time_stats',
rating_key: ratingKey,
grouping: 1,
},
await this.get<TautulliWatchStatsResponse>('/api/v2', {
cmd: 'get_item_watch_time_stats',
rating_key: ratingKey,
grouping: '1',
})
).data.response.data;
).response.data;
} catch (e) {
logger.error(
'Something went wrong fetching media watch stats from Tautulli',
@@ -176,14 +173,12 @@ class TautulliAPI {
): Promise<TautulliWatchUser[]> {
try {
return (
await this.axios.get<TautulliWatchUsersResponse>('/api/v2', {
params: {
cmd: 'get_item_user_stats',
rating_key: ratingKey,
grouping: 1,
},
await this.get<TautulliWatchUsersResponse>('/api/v2', {
cmd: 'get_item_user_stats',
rating_key: ratingKey,
grouping: '1',
})
).data.response.data;
).response.data;
} catch (e) {
logger.error(
'Something went wrong fetching media watch users from Tautulli',
@@ -206,15 +201,13 @@ class TautulliAPI {
}
return (
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
params: {
cmd: 'get_user_watch_time_stats',
user_id: user.plexId,
query_days: 0,
grouping: 1,
},
await this.get<TautulliWatchStatsResponse>('/api/v2', {
cmd: 'get_user_watch_time_stats',
user_id: user.plexId.toString(),
query_days: '0',
grouping: '1',
})
).data.response.data[0];
).response.data[0];
} catch (e) {
logger.error(
'Something went wrong fetching user watch stats from Tautulli',
@@ -245,19 +238,17 @@ class TautulliAPI {
while (results.length < 20) {
const tautulliData = (
await this.axios.get<TautulliHistoryResponse>('/api/v2', {
params: {
cmd: 'get_history',
grouping: 1,
order_column: 'date',
order_dir: 'desc',
user_id: user.plexId,
media_type: 'movie,episode',
length: take,
start,
},
await this.get<TautulliHistoryResponse>('/api/v2', {
cmd: 'get_history',
grouping: '1',
order_column: 'date',
order_dir: 'desc',
user_id: user.plexId.toString(),
media_type: 'movie,episode',
length: take.toString(),
start: start.toString(),
})
).data.response.data.data;
).response.data.data;
if (!tautulliData.length) {
return results;

View File

@@ -112,10 +112,10 @@ class TheMovieDb extends ExternalAPI {
},
{
nodeCache: cacheManager.getCache('tmdb').data,
// rateLimit: {
// maxRPS: 50,
// id: 'tmdb',
// },
rateLimit: {
maxRPS: 50,
id: 'tmdb',
},
}
);
this.region = region;
@@ -130,12 +130,10 @@ class TheMovieDb extends ExternalAPI {
}: SearchOptions): Promise<TmdbSearchMultiResponse> => {
try {
const data = await this.get<TmdbSearchMultiResponse>('/search/multi', {
params: {
query,
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
},
query,
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
});
return data;
@@ -158,13 +156,11 @@ class TheMovieDb extends ExternalAPI {
}: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => {
try {
const data = await this.get<TmdbSearchMovieResponse>('/search/movie', {
params: {
query,
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
primary_release_year: year?.toString() || '',
},
query,
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
primary_release_year: year?.toString() || '',
});
return data;
@@ -187,13 +183,11 @@ class TheMovieDb extends ExternalAPI {
}: SingleSearchOptions): Promise<TmdbSearchTvResponse> => {
try {
const data = await this.get<TmdbSearchTvResponse>('/search/tv', {
params: {
query,
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
first_air_date_year: year?.toString() || '',
},
query,
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
first_air_date_year: year?.toString() || '',
});
return data;
@@ -216,9 +210,7 @@ class TheMovieDb extends ExternalAPI {
}): Promise<TmdbPersonDetails> => {
try {
const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, {
params: {
language,
},
language,
});
return data;
@@ -238,9 +230,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbPersonCombinedCredits>(
`/person/${personId}/combined_credits`,
{
params: {
language,
},
language,
}
);
@@ -263,11 +253,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbMovieDetails>(
`/movie/${movieId}`,
{
params: {
language,
append_to_response:
'credits,external_ids,videos,keywords,release_dates,watch/providers',
},
language,
append_to_response:
'credits,external_ids,videos,keywords,release_dates,watch/providers',
},
43200
);
@@ -289,11 +277,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbTvDetails>(
`/tv/${tvId}`,
{
params: {
language,
append_to_response:
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
},
language,
append_to_response:
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
},
43200
);
@@ -317,10 +303,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSeasonWithEpisodes>(
`/tv/${tvId}/season/${seasonNumber}`,
{
params: {
language: language || '',
append_to_response: 'external_ids',
},
language: language || '',
append_to_response: 'external_ids',
}
);
@@ -343,10 +327,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/recommendations`,
{
params: {
page: page.toString(),
language,
},
page: page.toString(),
language,
}
);
@@ -369,10 +351,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/similar`,
{
params: {
page: page.toString(),
language,
},
page: page.toString(),
language,
}
);
@@ -395,10 +375,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>(
`/keyword/${keywordId}/movies`,
{
params: {
page: page.toString(),
language,
},
page: page.toString(),
language,
}
);
@@ -421,10 +399,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchTvResponse>(
`/tv/${tvId}/recommendations`,
{
params: {
page: page.toString(),
language,
},
page: page.toString(),
language,
}
);
@@ -447,10 +423,8 @@ class TheMovieDb extends ExternalAPI {
}): Promise<TmdbSearchTvResponse> {
try {
const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, {
params: {
page: page.toString(),
language,
},
page: page.toString(),
language,
});
return data;
@@ -491,40 +465,38 @@ class TheMovieDb extends ExternalAPI {
.split('T')[0];
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
params: {
sort_by: sortBy,
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
region: this.region || '',
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? ''
: this.originalLanguage || '',
// Set our release date values, but check if one is set and not the other,
// so we can force a past date or a future date. TMDB Requires both values if one is set!
'primary_release_date.gte':
!primaryReleaseDateGte && primaryReleaseDateLte
? defaultPastDate
: primaryReleaseDateGte || '',
'primary_release_date.lte':
!primaryReleaseDateLte && primaryReleaseDateGte
? defaultFutureDate
: primaryReleaseDateLte || '',
with_genres: genre || '',
with_companies: studio || '',
with_keywords: keywords || '',
'with_runtime.gte': withRuntimeGte || '',
'with_runtime.lte': withRuntimeLte || '',
'vote_average.gte': voteAverageGte || '',
'vote_average.lte': voteAverageLte || '',
'vote_count.gte': voteCountGte || '',
'vote_count.lte': voteCountLte || '',
watch_region: watchRegion || '',
with_watch_providers: watchProviders || '',
},
sort_by: sortBy,
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
region: this.region || '',
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? ''
: this.originalLanguage || '',
// Set our release date values, but check if one is set and not the other,
// so we can force a past date or a future date. TMDB Requires both values if one is set!
'primary_release_date.gte':
!primaryReleaseDateGte && primaryReleaseDateLte
? defaultPastDate
: primaryReleaseDateGte || '',
'primary_release_date.lte':
!primaryReleaseDateLte && primaryReleaseDateGte
? defaultFutureDate
: primaryReleaseDateLte || '',
with_genres: genre || '',
with_companies: studio || '',
with_keywords: keywords || '',
'with_runtime.gte': withRuntimeGte || '',
'with_runtime.lte': withRuntimeLte || '',
'vote_average.gte': voteAverageGte || '',
'vote_average.lte': voteAverageLte || '',
'vote_count.gte': voteCountGte || '',
'vote_count.lte': voteCountLte || '',
watch_region: watchRegion || '',
with_watch_providers: watchProviders || '',
});
return data;
@@ -566,43 +538,41 @@ class TheMovieDb extends ExternalAPI {
.split('T')[0];
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
params: {
sort_by: sortBy,
page: page.toString(),
language,
region: this.region || '',
// Set our release date values, but check if one is set and not the other,
// so we can force a past date or a future date. TMDB Requires both values if one is set!
'first_air_date.gte':
!firstAirDateGte && firstAirDateLte
? defaultPastDate
: firstAirDateGte || '',
'first_air_date.lte':
!firstAirDateLte && firstAirDateGte
? defaultFutureDate
: firstAirDateLte || '',
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? ''
: this.originalLanguage || '',
include_null_first_air_dates: includeEmptyReleaseDate
? 'true'
: 'false',
with_genres: genre || '',
with_networks: network?.toString() || '',
with_keywords: keywords || '',
'with_runtime.gte': withRuntimeGte || '',
'with_runtime.lte': withRuntimeLte || '',
'vote_average.gte': voteAverageGte || '',
'vote_average.lte': voteAverageLte || '',
'vote_count.gte': voteCountGte || '',
'vote_count.lte': voteCountLte || '',
with_watch_providers: watchProviders || '',
watch_region: watchRegion || '',
with_status: withStatus || '',
},
sort_by: sortBy,
page: page.toString(),
language,
region: this.region || '',
// Set our release date values, but check if one is set and not the other,
// so we can force a past date or a future date. TMDB Requires both values if one is set!
'first_air_date.gte':
!firstAirDateGte && firstAirDateLte
? defaultPastDate
: firstAirDateGte || '',
'first_air_date.lte':
!firstAirDateLte && firstAirDateGte
? defaultFutureDate
: firstAirDateLte || '',
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? ''
: this.originalLanguage || '',
include_null_first_air_dates: includeEmptyReleaseDate
? 'true'
: 'false',
with_genres: genre || '',
with_networks: network?.toString() || '',
with_keywords: keywords || '',
'with_runtime.gte': withRuntimeGte || '',
'with_runtime.lte': withRuntimeLte || '',
'vote_average.gte': voteAverageGte || '',
'vote_average.lte': voteAverageLte || '',
'vote_count.gte': voteCountGte || '',
'vote_count.lte': voteCountLte || '',
with_watch_providers: watchProviders || '',
watch_region: watchRegion || '',
with_status: withStatus || '',
});
return data;
@@ -622,12 +592,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbUpcomingMoviesResponse>(
'/movie/upcoming',
{
params: {
page: page.toString(),
language,
region: this.region || '',
originalLanguage: this.originalLanguage || '',
},
page: page.toString(),
language,
region: this.region || '',
originalLanguage: this.originalLanguage || '',
}
);
@@ -650,11 +618,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMultiResponse>(
`/trending/all/${timeWindow}`,
{
params: {
page: page.toString(),
language,
region: this.region || '',
},
page: page.toString(),
language,
region: this.region || '',
}
);
@@ -675,9 +641,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>(
`/trending/movie/${timeWindow}`,
{
params: {
page: page.toString(),
},
page: page.toString(),
}
);
@@ -698,9 +662,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchTvResponse>(
`/trending/tv/${timeWindow}`,
{
params: {
page: page.toString(),
},
page: page.toString(),
}
);
@@ -729,10 +691,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbExternalIdResponse>(
`/find/${externalId}`,
{
params: {
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
language,
},
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
language,
}
);
@@ -822,9 +782,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbCollection>(
`/collection/${collectionId}`,
{
params: {
language,
},
language,
}
);
@@ -897,9 +855,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbGenresResult>(
'/genre/movie/list',
{
params: {
language,
},
language,
},
86400 // 24 hours
);
@@ -911,9 +867,7 @@ class TheMovieDb extends ExternalAPI {
const englishData = await this.get<TmdbGenresResult>(
'/genre/movie/list',
{
params: {
language: 'en',
},
language: 'en',
},
86400 // 24 hours
);
@@ -948,9 +902,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbGenresResult>(
'/genre/tv/list',
{
params: {
language,
},
language,
},
86400 // 24 hours
);
@@ -962,9 +914,7 @@ class TheMovieDb extends ExternalAPI {
const englishData = await this.get<TmdbGenresResult>(
'/genre/tv/list',
{
params: {
language: 'en',
},
language: 'en',
},
86400 // 24 hours
);
@@ -1019,10 +969,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbKeywordSearchResponse>(
'/search/keyword',
{
params: {
query,
page: page.toString(),
},
query,
page: page.toString(),
},
86400 // 24 hours
);
@@ -1044,10 +992,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbCompanySearchResponse>(
'/search/company',
{
params: {
query,
page: page.toString(),
},
query,
page: page.toString(),
},
86400 // 24 hours
);
@@ -1067,9 +1013,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
'/watch/providers/regions',
{
params: {
language: language ? this.originalLanguage || '' : '',
},
language: language ? this.originalLanguage || '' : '',
},
86400 // 24 hours
);
@@ -1093,10 +1037,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/movie',
{
params: {
language: language ? this.originalLanguage || '' : '',
watch_region: watchRegion,
},
language: language ? this.originalLanguage || '' : '',
watch_region: watchRegion,
},
86400 // 24 hours
);
@@ -1120,10 +1062,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/tv',
{
params: {
language: language ? this.originalLanguage || '' : '',
watch_region: watchRegion,
},
language: language ? this.originalLanguage || '' : '',
watch_region: watchRegion,
},
86400 // 24 hours
);

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';
@@ -53,12 +51,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 () => {
@@ -75,11 +67,6 @@ app
const settings = await getSettings().load();
restartFlag.initializeSettings(settings.main);
// Register HTTP proxy
if (settings.main.proxy.enabled) {
await createCustomProxyAgent(settings.main.proxy);
}
// Migrate library types
if (
settings.plex.libraries.length > 1 &&

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,6 @@ export interface MainSettings {
mediaServerType: number;
partialRequestsEnabled: boolean;
locale: string;
proxy: ProxySettings;
}
interface PublicSettings {
@@ -337,16 +325,6 @@ class Settings {
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
locale: 'en',
proxy: {
enabled: false,
hostname: '',
port: 8080,
useSsl: false,
user: '',
password: '',
bypassFilter: '',
bypassLocalAddresses: true,
},
},
plex: {
name: '',
@@ -501,6 +479,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 +584,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 +618,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 +641,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

@@ -27,14 +27,8 @@ const migrateApiTokens = async (settings: any): Promise<AllSettings> => {
admin.jellyfinDeviceId
);
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
try {
const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
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."
);
}
const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
settings.jellyfin.apiKey = apiKey;
}
return settings;
};

View File

@@ -1,6 +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');
@@ -9,56 +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) {
// 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();
}
migrated = await migration(migrated);
}
const settingsAfter = JSON.stringify(migrated);
@@ -66,33 +30,30 @@ 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) {
// we stop jellyseerr if the migration failed
logger.error(
`Something went wrong while running settings migrations: ${e.message}`,
{
label: 'Settings Migrator',
}
{ label: 'Settings Migrator' }
);
logger.error(
'A common cause for this issue is a permission error of your configuration folder.',
{
label: 'Settings Migrator',
}
// we stop jellyseerr if the migration failed
console.log(
'===================================================================='
);
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();
}

View File

@@ -87,7 +87,7 @@ authRoutes.post('/plex', async (req, res, next) => {
});
settings.main.mediaServerType = MediaServerType.PLEX;
await settings.save();
settings.save();
startJobs();
await userRepository.save(user);
@@ -299,84 +299,54 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
where: { jellyfinUserId: account.User.Id },
});
const missingAdminUser = !user && !(await userRepository.count());
if (
missingAdminUser ||
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED
) {
if (!user && !(await userRepository.count())) {
// Check if user is admin on jellyfin
if (account.User.Policy.IsAdministrator === false) {
throw new ApiError(403, ApiErrorCode.NotAdmin);
}
if (
body.serverType !== MediaServerType.JELLYFIN &&
body.serverType !== MediaServerType.EMBY
) {
throw new Error('select_server_type');
}
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,
logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr',
{
label: 'API',
ip: req.ip,
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: `/avatarproxy/${account.User.Id}`,
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: `/avatarproxy/${account.User.Id}`,
userType: UserType.JELLYFIN,
});
break;
default:
throw new Error('select_server_type');
}
// Create an API key on Jellyfin from this admin user
@@ -396,8 +366,10 @@ 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);
}
// User already exists, let's update their information
else if (account.User.Id === user?.jellyfinUserId) {

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

@@ -69,19 +69,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 +118,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 +231,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 +282,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 +370,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);
});
@@ -434,7 +434,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 +695,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 +709,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 +766,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

@@ -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

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

View File

@@ -55,17 +55,6 @@ 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',
});
const SettingsMain = () => {
@@ -93,12 +82,6 @@ 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)
),
}),
});
const regenerate = async () => {
@@ -154,14 +137,6 @@ 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,
}}
enableReinitialize
validationSchema={MainSettingsSchema}
@@ -183,16 +158,6 @@ 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,
},
}),
});
if (!res.ok) throw new Error();
@@ -472,176 +437,6 @@ const SettingsMain = () => {
/>
</div>
</div>
<div className="form-row">
<label htmlFor="proxyEnabled" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.proxyEnabled)}
</span>
<SettingsBadge badgeType="advanced" className="mr-2" />
<SettingsBadge badgeType="restartRequired" />
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="proxyEnabled"
name="proxyEnabled"
onChange={() => {
setFieldValue('proxyEnabled', !values.proxyEnabled);
}}
/>
</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)}