Compare commits

...

32 Commits

Author SHA1 Message Date
Gauthier
3c8fb4f4bb fix: correct sonarr override rules 2024-11-19 23:00:28 +01:00
Gauthier
98ecdf16d2 fix: add missing migration 2024-11-18 22:07:25 +01:00
Gauthier
a8bd51f8ab style: run prettier 2024-11-17 20:25:52 +01:00
Gauthier
6ee96604ec fix: resolve type errors 2024-11-17 20:25:50 +01:00
Gauthier
aa3670322d fix: save the settings modified by the override rules 2024-11-17 20:25:49 +01:00
Gauthier
38eaa30cee feat: add users to override rules 2024-11-17 20:25:48 +01:00
Gauthier
c0fce76db7 feat: apply override rules in the media request 2024-11-17 20:25:46 +01:00
Gauthier
92341c8d06 feat: add support for sonarr and keywords to override rules 2024-11-17 20:25:45 +01:00
Gauthier
cbcb71436d feat: create the basis for the override rules 2024-11-17 20:25:43 +01:00
Gauthier
39a5ccb7f3 fix(usersettings): allow unset email and add more explicit email error message (#1096) 2024-11-16 16:55:38 +01:00
Gauthier
9b151feb4f fix(ui): allow thetvdb images for unmatched series (#1105)
When a series has no equivalent in TheTVDB used by Sonarr, a popup is displayed to select the series
on TheTVDB. The images in this popup come from artworks.thetvdb.com, which was not an authorized
domain for Next.js images.

fix #1075
2024-11-16 15:33:26 +01:00
Gauthier
fe5d016929 fix(ui): resize streaming service logos (#1106)
The size of streaming service logos has been changed due to the Next.js Image component update.

fix #1103
2024-11-16 15:26:48 +01:00
Gauthier
14f316a9a6 fix: use less strict validation for external URLs (#1104)
* fix: use less strict validation for external URLs

Default url validation from the Yup module doesn't allow URLs like "http://custom-host", while it is
a correct value for an external URL.

fix #1068

* fix: resolve GitHub CodeQL review
2024-11-16 15:26:31 +01:00
Guillaume ARNOUX
5c24e79b1d feat(notifications): improve discord notifications (#1102)
* feat: improve discord notifications

Added a field in the general notification settings to allow a role to be mentioned in the webhook
message via discord notification agent

* feat: add discord role id notification - locales
2024-11-15 18:38:23 +01:00
Joe
ba84212e68 docs: added missing sub_filters in nginx subpath (#1052)
* Update reverse-proxy.mdx

Fix posters not showing up when using sub-folder (at least with NPM)

* Update docs/extending-jellyseerr/reverse-proxy.mdx

Added `avatarproxy` support

Co-authored-by: Gauthier <mail@gauthierth.fr>

---------

Co-authored-by: Gauthier <mail@gauthierth.fr>
2024-11-13 10:54:18 +08:00
Gauthier
f25b32aec8 fix: update i18n translations (#1090) 2024-11-12 22:31:15 +01:00
Fallenbagel
5a13226877 refactor(jellyfinapi): improve logging of jellyfin api (#1089)
This commit improves the logging of jellyfin api. This will also fix the wrong logging of throwing
an invalid credentials when jellyfin/emby is unreachable.

re #1053
2024-11-12 22:26:56 +01:00
Gauthier
694913c767 fix(blacklist): request data only when modal is shown, remove useless ratelimit and lazy load blacklist (#1084)
* perf: remove eager load of Blacklist entity from Media entity

Try to resolve some performance issues by removing the eager loading of Blacklist items from the
Media entity

* fix: fix ManageSlideOver for blacklist

* perf(blacklist): request data only when modal is shown

For admin users, the button to blacklist a media (used on every media card) was displaying a Modal,
that was requesting data BEFORE the modal was displayed. This resulted in dozens of additional
requests everytime media cards were displayed.

* perf(blacklist): remove useless ratelimit
2024-11-13 03:01:06 +08:00
Gauthier
a2d2fd3c2a fix(i18n): update extractMessages function for better escaping of characters (#1079)
This PR fix a bug when a translation message has two single quote like "message": "hello 'world'",
the extractMessages function was escaping the message correcly.
2024-11-13 02:58:33 +08:00
Gauthier
cb94ad5a2e docs: fix pnpm install command (#1086) 2024-11-11 23:31:55 +08:00
Gauthier
2829c2548a fix(setup): add leading slash validation for baseUrl (#1083) 2024-11-11 02:51:45 +08:00
Gauthier
64f4610b9f fix: resolve error when setup on second attempt (#1061) 2024-11-06 15:21:19 +08:00
Ludovic Ortega
2d3b777daf docs: migrate to docker compose v2 (#1073)
Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2024-11-04 22:48:37 +08:00
Fallenbagel
cf59102ef9 fix(externalapi): extract basic auth and pass it through header (#1062)
This commit adds extraction of basic authentication credentials from the URL and then pass the
credentials as the `Authorization` header. And then credentials are removed from the URL before
being passed to fetch. This is done because fetch request cannot be constructed using a URL with
credentials

fix #1027
2024-11-03 14:35:20 +08:00
Gauthier
ca838a00fa feat: add bypass list, bypass local addresses and username/password to proxy setting (#1059)
* fix: use fs/promises for settings

This PR switches from synchronous operations with the 'fs' module to asynchronous operations with
the 'fs/promises' module. It also corrects a small error with hostname migration.

* fix: add missing merge function of default and current config

* feat: add bypass list, bypass local addresses and username/password to proxy setting

This PR adds more options to the proxy setting, like username/password authentication, bypass list
of domains and bypass local addresses. The UX is taken from *arrs.

* fix: add error handling for proxy creating

* fix: remove logs
2024-10-31 16:10:45 +01:00
Gauthier
f2ed101e52 fix: use fs/promises for settings (#1057)
* fix: use fs/promises for settings

This PR switches from synchronous operations with the 'fs' module to asynchronous operations with
the 'fs/promises' module. It also corrects a small error with hostname migration.

* fix: add missing merge function of default and current config

* refactor: add more logs to migration
2024-10-31 15:51:57 +01:00
Gauthier
4b4eeb6ec7 feat: proxy setting (#1031)
* feat: add a proxy option into settings

* feat: add a proxy option into settings

* fix: use undici proxy agent
2024-10-26 12:19:42 +02:00
Gauthier
d331798b28 fix: remove language profiles dropdown for Sonarr v4 (#1000)
Currently, the language profiles removed with Sonarr v4 are still available for compatibility
reasons. However, Jellyseerr still queries and displays language profiles (marking them as
“Deprecated”). This PR hides and does not query language profiles unless Sonarr v3 is used.

fix #207
2024-10-24 18:34:01 +02:00
Gauthier
f2b63156d1 feat: add a warning if permissions are missing from config folder (#1030) 2024-10-24 18:13:11 +02:00
Gauthier
326001c3ec feat: add more logs to migrations and create a settings backup (#1036)
* feat: add more logs to migrations and create a settings backup

* fix: avoid backup to be replaced at next startup

* fix: resolve review comments

* fix: try to fix CodeQL warnings
2024-10-24 18:12:42 +02:00
Gauthier
0bbcfcbd5e fix: cache Jellyfin/Emby avatars from API (#1045)
* 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.

* fix: update avatar on new login
2024-10-24 18:11:25 +02:00
Fallenbagel
32e0b129fe docs(aur): add disclaimer about being maintained by third-party (#1044) 2024-10-22 05:20:14 +08:00
68 changed files with 2667 additions and 627 deletions

View File

@@ -18,7 +18,7 @@ config/logs/*
config/*.json config/*.json
dist dist
Dockerfile* Dockerfile*
docker-compose.yml compose.yaml
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
docker-compose.yml export-ignore compose.yaml 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,6 +34,7 @@ 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

@@ -48,11 +48,11 @@ All help is welcome and greatly appreciated! If you would like to contribute to
4. Run the development environment: 4. Run the development environment:
```bash ```bash
pnpm pnpm install
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,4 +1,3 @@
version: '3'
services: services:
jellyseerr: jellyseerr:
build: build:

View File

@@ -75,6 +75,7 @@
"types": 0, "types": 0,
"options": { "options": {
"webhookUrl": "", "webhookUrl": "",
"webhookRoleId": "",
"enableMentions": true "enableMentions": true
} }
}, },

View File

@@ -95,6 +95,8 @@ location ^~ /jellyseerr {
sub_filter '/api/v1' '/$app/api/v1'; sub_filter '/api/v1' '/$app/api/v1';
sub_filter '/login/plex/loading' '/$app/login/plex/loading'; sub_filter '/login/plex/loading' '/$app/login/plex/loading';
sub_filter '/images/' '/$app/images/'; sub_filter '/images/' '/$app/images/';
sub_filter '/imageproxy/' '/$app/imageproxy/';
sub_filter '/avatarproxy/' '/$app/avatarproxy/';
sub_filter '/android-' '/$app/android-'; sub_filter '/android-' '/$app/android-';
sub_filter '/apple-' '/$app/apple-'; sub_filter '/apple-' '/$app/apple-';
sub_filter '/favicon' '/$app/favicon'; sub_filter '/favicon' '/$app/favicon';
@@ -190,7 +192,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 `docker-compose.yml` file: Add the following labels to the Jellyseerr service in your `compose.yaml` file:
```yaml ```yaml
labels: labels:

View File

@@ -6,6 +6,10 @@ 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 `docker-compose.yml` as follows: Define the `jellyseerr` service in your `compose.yaml` 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

@@ -18,6 +18,10 @@ Users can optionally opt-in to being mentioned in Discord notifications by confi
You can find the webhook URL in the Discord application, at **Server Settings &rarr; Integrations &rarr; Webhooks**. You can find the webhook URL in the Discord application, at **Server Settings &rarr; Integrations &rarr; Webhooks**.
### Notification Role ID (optional)
If a role ID is specified, it will be included in the webhook message. See [Discord role ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID).
### Bot Username (optional) ### Bot Username (optional)
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like! If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!

View File

@@ -10,6 +10,7 @@ module.exports = {
remotePatterns: [ remotePatterns: [
{ hostname: 'gravatar.com' }, { hostname: 'gravatar.com' },
{ hostname: 'image.tmdb.org' }, { hostname: 'image.tmdb.org' },
{ hostname: 'artworks.thetvdb.com' },
], ],
}, },
webpack(config) { webpack(config) {

View File

@@ -1273,6 +1273,8 @@ components:
type: string type: string
webhookUrl: webhookUrl:
type: string type: string
webhookRoleId:
type: string
enableMentions: enableMentions:
type: boolean type: boolean
SlackSettings: SlackSettings:
@@ -1930,6 +1932,11 @@ components:
type: string type: string
native_name: native_name:
type: string type: string
OverrideRule:
type: object
properties:
id:
type: string
securitySchemes: securitySchemes:
cookieAuth: cookieAuth:
type: apiKey type: apiKey
@@ -1988,6 +1995,9 @@ 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
@@ -3750,6 +3760,11 @@ paths:
type: string type: string
enum: [created, updated, requests, displayname] enum: [created, updated, requests, displayname]
default: created default: created
- in: query
name: q
required: false
schema:
type: string
responses: responses:
'200': '200':
description: A JSON array of all users description: A JSON array of all users
@@ -4139,6 +4154,21 @@ 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:
@@ -6936,6 +6966,68 @@ paths:
type: array type: array
items: items:
$ref: '#/components/schemas/WatchProviderDetails' $ref: '#/components/schemas/WatchProviderDetails'
/overrideRule:
get:
summary: Get override rules
description: Returns a list of all override rules with their conditions and settings
tags:
- overriderule
responses:
'200':
description: Override rules returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/OverrideRule'
post:
summary: Create override rule
description: Creates a new Override Rule from the request body.
tags:
- overriderule
responses:
'200':
description: 'Values were successfully created'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/OverrideRule'
/overrideRule/{ruleId}:
put:
summary: Update override rule
description: Updates an Override Rule from the request body.
tags:
- overriderule
responses:
'200':
description: 'Values were successfully updated'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/OverrideRule'
delete:
summary: Delete override rule by ID
description: Deletes the override rule with the provided ruleId.
tags:
- overriderule
parameters:
- in: path
name: ruleId
required: true
schema:
type: number
responses:
'200':
description: Override rule successfully deleted
content:
application/json:
schema:
$ref: '#/components/schemas/OverrideRule'
security: security:
- cookieAuth: [] - cookieAuth: []
- apiKey: [] - apiKey: []

View File

@@ -93,7 +93,8 @@
"sqlite3": "5.1.4", "sqlite3": "5.1.4",
"swagger-ui-express": "4.6.2", "swagger-ui-express": "4.6.2",
"swr": "2.2.5", "swr": "2.2.5",
"typeorm": "0.3.12", "typeorm": "0.3.11",
"undici": "^6.20.1",
"web-push": "3.5.0", "web-push": "3.5.0",
"winston": "3.8.2", "winston": "3.8.2",
"winston-daily-rotate-file": "4.7.1", "winston-daily-rotate-file": "4.7.1",

65
pnpm-lock.yaml generated
View File

@@ -49,7 +49,7 @@ importers:
version: 2.11.0 version: 2.11.0
connect-typeorm: connect-typeorm:
specifier: 1.1.4 specifier: 1.1.4
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))) 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)))
cookie-parser: cookie-parser:
specifier: 1.4.6 specifier: 1.4.6
version: 1.4.6 version: 1.4.6
@@ -192,8 +192,11 @@ importers:
specifier: 2.2.5 specifier: 2.2.5
version: 2.2.5(react@18.3.1) version: 2.2.5(react@18.3.1)
typeorm: typeorm:
specifier: 0.3.12 specifier: 0.3.11
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)) 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
web-push: web-push:
specifier: 3.5.0 specifier: 3.5.0
version: 3.5.0 version: 3.5.0
@@ -4264,10 +4267,6 @@ packages:
resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==} resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==}
engines: {node: '>=0.11'} 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: dateformat@3.0.3:
resolution: {integrity: sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==} resolution: {integrity: sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==}
@@ -5389,8 +5388,8 @@ packages:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
https-proxy-agent@7.0.4: https-proxy-agent@7.0.5:
resolution: {integrity: sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==} resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
human-signals@1.1.1: human-signals@1.1.1:
@@ -6554,11 +6553,6 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
mkdirp@2.1.6:
resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==}
engines: {node: '>=10'}
hasBin: true
modify-values@1.0.1: modify-values@1.0.1:
resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==} resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -7730,9 +7724,6 @@ packages:
reflect-metadata@0.1.13: reflect-metadata@0.1.13:
resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==} resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==}
reflect-metadata@0.1.14:
resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==}
reflect.getprototypeof@1.0.6: reflect.getprototypeof@1.0.6:
resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==} resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -8670,8 +8661,8 @@ packages:
typedarray@0.0.6: typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typeorm@0.3.12: typeorm@0.3.11:
resolution: {integrity: sha512-sYSxBmCf1nJLLTcYtwqZ+lQIRtLPyUoO93rHTOKk9vJCyT4UfRtU7oRsJvfvKP3nnZTD1hzz2SEy2zwPEN6OyA==} resolution: {integrity: sha512-pzdOyWbVuz/z8Ww6gqvBW4nylsM0KLdUCDExr2gR20/x1khGSVxQkjNV/3YqliG90jrWzrknYbYscpk8yxFJVg==}
engines: {node: '>= 12.9.0'} engines: {node: '>= 12.9.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -8682,7 +8673,7 @@ packages:
ioredis: ^5.0.4 ioredis: ^5.0.4
mongodb: ^3.6.0 mongodb: ^3.6.0
mssql: ^7.3.0 mssql: ^7.3.0
mysql2: ^2.2.5 || ^3.0.1 mysql2: ^2.2.5
oracledb: ^5.1.0 oracledb: ^5.1.0
pg: ^8.5.1 pg: ^8.5.1
pg-native: ^3.0.0 pg-native: ^3.0.0
@@ -8768,6 +8759,10 @@ packages:
undici-types@5.26.5: undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} 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: unicode-canonical-property-names-ecmascript@2.0.0:
resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -12310,7 +12305,7 @@ snapshots:
fs-extra: 11.2.0 fs-extra: 11.2.0
globby: 11.1.0 globby: 11.1.0
http-proxy-agent: 7.0.2 http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.4 https-proxy-agent: 7.0.5
issue-parser: 6.0.0 issue-parser: 6.0.0
lodash: 4.17.21 lodash: 4.17.21
mime: 3.0.0 mime: 3.0.0
@@ -13824,13 +13819,13 @@ snapshots:
ini: 1.3.8 ini: 1.3.8
proto-list: 1.2.4 proto-list: 1.2.4
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))): 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))):
dependencies: dependencies:
'@types/debug': 0.0.31 '@types/debug': 0.0.31
'@types/express-session': 1.17.6 '@types/express-session': 1.17.6
debug: 4.3.5(supports-color@8.1.1) debug: 4.3.5(supports-color@8.1.1)
express-session: 1.18.0 express-session: 1.18.0
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)) 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))
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -14181,10 +14176,6 @@ snapshots:
date-fns@2.29.3: {} date-fns@2.29.3: {}
date-fns@2.30.0:
dependencies:
'@babel/runtime': 7.24.7
dateformat@3.0.3: {} dateformat@3.0.3: {}
dayjs@1.11.11: {} dayjs@1.11.11: {}
@@ -15739,7 +15730,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
https-proxy-agent@7.0.4: https-proxy-agent@7.0.5:
dependencies: dependencies:
agent-base: 7.1.1 agent-base: 7.1.1
debug: 4.3.5(supports-color@8.1.1) debug: 4.3.5(supports-color@8.1.1)
@@ -17149,8 +17140,6 @@ snapshots:
mkdirp@1.0.4: {} mkdirp@1.0.4: {}
mkdirp@2.1.6: {}
modify-values@1.0.1: {} modify-values@1.0.1: {}
moment@2.30.1: {} moment@2.30.1: {}
@@ -18372,8 +18361,6 @@ snapshots:
reflect-metadata@0.1.13: {} reflect-metadata@0.1.13: {}
reflect-metadata@0.1.14: {}
reflect.getprototypeof@1.0.6: reflect.getprototypeof@1.0.6:
dependencies: dependencies:
call-bind: 1.0.7 call-bind: 1.0.7
@@ -19431,23 +19418,23 @@ snapshots:
typedarray@0.0.6: {} typedarray@0.0.6: {}
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)): 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)):
dependencies: dependencies:
'@sqltools/formatter': 1.2.5 '@sqltools/formatter': 1.2.5
app-root-path: 3.1.0 app-root-path: 3.1.0
buffer: 6.0.3 buffer: 6.0.3
chalk: 4.1.2 chalk: 4.1.2
cli-highlight: 2.1.11 cli-highlight: 2.1.11
date-fns: 2.30.0 date-fns: 2.29.3
debug: 4.3.5(supports-color@8.1.1) debug: 4.3.5(supports-color@8.1.1)
dotenv: 16.4.5 dotenv: 16.4.5
glob: 8.1.0 glob: 7.2.3
js-yaml: 4.1.0 js-yaml: 4.1.0
mkdirp: 2.1.6 mkdirp: 1.0.4
reflect-metadata: 0.1.14 reflect-metadata: 0.1.13
sha.js: 2.4.11 sha.js: 2.4.11
tslib: 2.6.3 tslib: 2.6.3
uuid: 9.0.1 uuid: 8.3.2
xml2js: 0.4.23 xml2js: 0.4.23
yargs: 17.7.2 yargs: 17.7.2
optionalDependencies: optionalDependencies:
@@ -19486,6 +19473,8 @@ snapshots:
undici-types@5.26.5: {} undici-types@5.26.5: {}
undici@6.20.1: {}
unicode-canonical-property-names-ecmascript@2.0.0: {} unicode-canonical-property-names-ecmascript@2.0.0: {}
unicode-emoji-utils@1.2.0: unicode-emoji-utils@1.2.0:

View File

@@ -32,13 +32,27 @@ class ExternalAPI {
this.fetch = fetch; this.fetch = fetch;
} }
this.baseUrl = baseUrl; const url = new URL(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

@@ -138,39 +138,38 @@ class JellyfinAPI extends ExternalAPI {
try { try {
return await authenticate(true); return await authenticate(true);
} catch (e) { } catch (e) {
logger.debug(`Failed to authenticate with headers: ${e.message}`, { logger.debug('Failed to authenticate with headers', {
label: 'Jellyfin API', label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
ip: ClientIP, ip: ClientIP,
}); });
if (!e.cause.status) {
throw new ApiError(404, ApiErrorCode.InvalidUrl);
}
if (e.cause.status === 401) {
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
}
} }
try { try {
return await authenticate(false); return await authenticate(false);
} catch (e) { } catch (e) {
const status = e.cause?.status; if (e.cause.status === 401) {
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
const networkErrorCodes = new Set([
'ECONNREFUSED',
'EHOSTUNREACH',
'ENOTFOUND',
'ETIMEDOUT',
'ECONNRESET',
'EADDRINUSE',
'ENETDOWN',
'ENETUNREACH',
'EPIPE',
'ECONNABORTED',
'EPROTO',
'EHOSTDOWN',
'EAI_AGAIN',
'ERR_INVALID_URL',
]);
if (networkErrorCodes.has(e.code) || status === 404) {
throw new ApiError(status, ApiErrorCode.InvalidUrl);
} }
throw new ApiError(status, ApiErrorCode.InvalidCredentials); logger.error(
'Something went wrong while authenticating with the Jellyfin server',
{
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
ip: ClientIP,
}
);
throw new ApiError(e.cause.status, ApiErrorCode.Unknown);
} }
} }
@@ -198,8 +197,8 @@ class JellyfinAPI extends ExternalAPI {
return serverResponse.ServerName; return serverResponse.ServerName;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`, 'Something went wrong while getting the server name from the Jellyfin server',
{ label: 'Jellyfin API' } { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown); throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
@@ -213,8 +212,8 @@ class JellyfinAPI extends ExternalAPI {
return { users: userReponse }; return { users: userReponse };
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`, 'Something went wrong while getting the account from the Jellyfin server',
{ label: 'Jellyfin API' } { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -229,8 +228,8 @@ class JellyfinAPI extends ExternalAPI {
return userReponse; return userReponse;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`, 'Something went wrong while getting the account from the Jellyfin server',
{ label: 'Jellyfin API' } { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -253,8 +252,11 @@ class JellyfinAPI extends ExternalAPI {
return this.mapLibraries(mediaFolderResponse.Items); return this.mapLibraries(mediaFolderResponse.Items);
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`, 'Something went wrong while getting libraries from the Jellyfin server',
{ label: 'Jellyfin API' } {
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
}
); );
return []; return [];
@@ -308,8 +310,8 @@ class JellyfinAPI extends ExternalAPI {
); );
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`, 'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API' } { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -329,8 +331,8 @@ class JellyfinAPI extends ExternalAPI {
return itemResponse; return itemResponse;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`, 'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API' } { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -354,8 +356,8 @@ class JellyfinAPI extends ExternalAPI {
} }
logger.error( logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`, 'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API' } { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
} }
@@ -368,8 +370,8 @@ class JellyfinAPI extends ExternalAPI {
return seasonResponse.Items; return seasonResponse.Items;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`, 'Something went wrong while getting the list of seasons from the Jellyfin server',
{ label: 'Jellyfin API' } { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -393,8 +395,8 @@ class JellyfinAPI extends ExternalAPI {
); );
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`, 'Something went wrong while getting the list of episodes from the Jellyfin server',
{ label: 'Jellyfin API' } { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -410,8 +412,8 @@ class JellyfinAPI extends ExternalAPI {
).AccessToken; ).AccessToken;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while creating an API key the Jellyfin server: ${e.message}`, 'Something went wrong while creating an API key from the Jellyfin server',
{ label: 'Jellyfin API' } { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
); );
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);

View File

@@ -180,7 +180,7 @@ class PlexAPI {
settings.plex.libraries = []; settings.plex.libraries = [];
} }
settings.save(); await 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: blacklist, blacklist: Promise.resolve(blacklist),
}); });
await mediaRepository.save(media); await mediaRepository.save(media);
} else { } else {
media.blacklist = blacklist; media.blacklist = Promise.resolve(blacklist);
media.status = MediaStatus.BLACKLISTED; media.status = MediaStatus.BLACKLISTED;
media.status4k = MediaStatus.BLACKLISTED; media.status4k = MediaStatus.BLACKLISTED;

View File

@@ -118,10 +118,8 @@ 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)
eager: true, public blacklist: Promise<Blacklist>;
})
public blacklist: Blacklist;
@CreateDateColumn() @CreateDateColumn()
public createdAt: Date; public createdAt: Date;

View File

@@ -13,6 +13,7 @@ import {
MediaType, MediaType,
} from '@server/constants/media'; } from '@server/constants/media';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule';
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces'; import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
import notificationManager, { Notification } from '@server/lib/notifications'; import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
@@ -713,48 +714,6 @@ export class MediaRequest {
return; return;
} }
let rootFolder = radarrSettings.activeDirectory;
let qualityProfile = radarrSettings.activeProfileId;
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
if (
this.rootFolder &&
this.rootFolder !== '' &&
this.rootFolder !== radarrSettings.activeDirectory
) {
rootFolder = this.rootFolder;
logger.info(`Request has an override root folder: ${rootFolder}`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
}
if (
this.profileId &&
this.profileId !== radarrSettings.activeProfileId
) {
qualityProfile = this.profileId;
logger.info(
`Request has an override quality profile ID: ${qualityProfile}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
}
if (this.tags && !isEqual(this.tags, radarrSettings.tags)) {
tags = this.tags;
logger.info(`Request has override tags`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
tagIds: tags,
});
}
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
const radarr = new RadarrAPI({ const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey, apiKey: radarrSettings.apiKey,
@@ -775,6 +734,151 @@ export class MediaRequest {
return; return;
} }
let rootFolder = radarrSettings.activeDirectory;
let qualityProfile = radarrSettings.activeProfileId;
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
const overrideRuleRepository = getRepository(OverrideRule);
const overrideRules = await overrideRuleRepository.find({
where: { radarrServiceId: radarrSettings.id },
});
const appliedOverrideRules = overrideRules.filter((rule) => {
if (
rule.users &&
!rule.users
.split(',')
.some((userId) => Number(userId) === this.requestedBy.id)
) {
return false;
}
if (
rule.genre &&
!rule.genre
.split(',')
.some((genreId) =>
movie.genres.map((genre) => genre.id).includes(Number(genreId))
)
) {
return false;
}
if (
rule.language &&
!rule.language
.split('|')
.some((languageId) => languageId === movie.original_language)
) {
return false;
}
if (
rule.keywords &&
!rule.keywords
.split(',')
.some((keywordId) =>
movie.keywords.keywords
.map((keyword) => keyword.id)
.includes(Number(keywordId))
)
) {
return false;
}
return true;
});
if (
this.rootFolder &&
this.rootFolder !== '' &&
this.rootFolder !== radarrSettings.activeDirectory
) {
rootFolder = this.rootFolder;
logger.info(
`Request has a manually overriden root folder: ${rootFolder}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
} else {
const overrideRootFolder = appliedOverrideRules.find(
(rule) => rule.rootFolder
)?.rootFolder;
if (overrideRootFolder) {
rootFolder = overrideRootFolder;
this.rootFolder = rootFolder;
logger.info(
`Request has an override root folder from override rules: ${rootFolder}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
}
}
if (
this.profileId &&
this.profileId !== radarrSettings.activeProfileId
) {
qualityProfile = this.profileId;
logger.info(
`Request has a manually overriden quality profile ID: ${qualityProfile}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
} else {
const overrideProfileId = appliedOverrideRules.find(
(rule) => rule.profileId
)?.profileId;
if (overrideProfileId) {
qualityProfile = overrideProfileId;
this.profileId = qualityProfile;
logger.info(
`Request has an override quality profile ID from override rules: ${qualityProfile}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
}
}
if (this.tags && !isEqual(this.tags, radarrSettings.tags)) {
tags = this.tags;
logger.info(`Request has manually overriden tags`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
tagIds: tags,
});
} else {
const overrideTags = appliedOverrideRules.find(
(rule) => rule.tags
)?.tags;
if (overrideTags) {
tags = [
...new Set([
...tags,
...overrideTags.split(',').map((tag) => Number(tag)),
]),
];
this.tags = tags;
logger.info(`Request has override tags from override rules`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
tagIds: tags,
});
}
}
const requestRepository = getRepository(MediaRequest);
requestRepository.save(this);
if (radarrSettings.tagRequests) { if (radarrSettings.tagRequests) {
let userTag = (await radarr.getTags()).find((v) => let userTag = (await radarr.getTags()).find((v) =>
v.label.startsWith(this.requestedBy.id + ' - ') v.label.startsWith(this.requestedBy.id + ' - ')
@@ -816,7 +920,6 @@ export class MediaRequest {
mediaId: this.media.id, mediaId: this.media.id,
}); });
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.APPROVED; this.status = MediaRequestStatus.APPROVED;
await requestRepository.save(this); await requestRepository.save(this);
return; return;
@@ -856,8 +959,6 @@ export class MediaRequest {
await mediaRepository.save(media); await mediaRepository.save(media);
}) })
.catch(async () => { .catch(async () => {
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED; this.status = MediaRequestStatus.FAILED;
requestRepository.save(this); requestRepository.save(this);
@@ -957,6 +1058,7 @@ export class MediaRequest {
throw new Error('Media data not found'); throw new Error('Media data not found');
} }
const requestRepository = getRepository(MediaRequest);
if ( if (
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) { ) {
@@ -966,7 +1068,6 @@ export class MediaRequest {
mediaId: this.media.id, mediaId: this.media.id,
}); });
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.APPROVED; this.status = MediaRequestStatus.APPROVED;
await requestRepository.save(this); await requestRepository.save(this);
return; return;
@@ -981,7 +1082,6 @@ export class MediaRequest {
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
if (!tvdbId) { if (!tvdbId) {
const requestRepository = getRepository(MediaRequest);
await mediaRepository.remove(media); await mediaRepository.remove(media);
await requestRepository.remove(this); await requestRepository.remove(this);
throw new Error('TVDB ID not found'); throw new Error('TVDB ID not found');
@@ -1019,29 +1119,110 @@ export class MediaRequest {
? [...sonarrSettings.tags] ? [...sonarrSettings.tags]
: []; : [];
const overrideRuleRepository = getRepository(OverrideRule);
const overrideRules = await overrideRuleRepository.find({
where: { sonarrServiceId: sonarrSettings.id },
});
const appliedOverrideRules = overrideRules.filter((rule) => {
if (
rule.users &&
!rule.users
.split(',')
.some((userId) => Number(userId) === this.requestedBy.id)
) {
return false;
}
if (
rule.genre &&
!rule.genre
.split(',')
.some((genreId) =>
series.genres.map((genre) => genre.id).includes(Number(genreId))
)
) {
return false;
}
if (
rule.language &&
!rule.language
.split('|')
.some((languageId) => languageId === series.original_language)
) {
return false;
}
if (
rule.keywords &&
!rule.keywords
.split(',')
.some((keywordId) =>
series.keywords.results
.map((keyword) => keyword.id)
.includes(Number(keywordId))
)
) {
return false;
}
return true;
});
if ( if (
this.rootFolder && this.rootFolder &&
this.rootFolder !== '' && this.rootFolder !== '' &&
this.rootFolder !== rootFolder this.rootFolder !== rootFolder
) { ) {
rootFolder = this.rootFolder; rootFolder = this.rootFolder;
logger.info(`Request has an override root folder: ${rootFolder}`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
}
if (this.profileId && this.profileId !== qualityProfile) {
qualityProfile = this.profileId;
logger.info( logger.info(
`Request has an override quality profile ID: ${qualityProfile}`, `Request has a manually overriden root folder: ${rootFolder}`,
{ {
label: 'Media Request', label: 'Media Request',
requestId: this.id, requestId: this.id,
mediaId: this.media.id, mediaId: this.media.id,
} }
); );
} else {
const overrideRootFolder = appliedOverrideRules.find(
(rule) => rule.rootFolder
)?.rootFolder;
if (overrideRootFolder) {
rootFolder = overrideRootFolder;
this.rootFolder = rootFolder;
logger.info(
`Request has an override root folder from override rules: ${rootFolder}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
}
}
if (this.profileId && this.profileId !== qualityProfile) {
qualityProfile = this.profileId;
logger.info(
`Request has a manually overriden quality profile ID: ${qualityProfile}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
} else {
const overrideProfileId = appliedOverrideRules.find(
(rule) => rule.profileId
)?.profileId;
if (overrideProfileId) {
qualityProfile = overrideProfileId;
this.profileId = qualityProfile;
logger.info(
`Request has an override quality profile ID from override rules: ${qualityProfile}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
}
} }
if ( if (
@@ -1061,12 +1242,31 @@ export class MediaRequest {
if (this.tags && !isEqual(this.tags, tags)) { if (this.tags && !isEqual(this.tags, tags)) {
tags = this.tags; tags = this.tags;
logger.info(`Request has override tags`, { logger.info(`Request has manually overriden tags`, {
label: 'Media Request', label: 'Media Request',
requestId: this.id, requestId: this.id,
mediaId: this.media.id, mediaId: this.media.id,
tagIds: tags, tagIds: tags,
}); });
} else {
const overrideTags = appliedOverrideRules.find(
(rule) => rule.tags
)?.tags;
if (overrideTags) {
tags = [
...new Set([
...tags,
...overrideTags.split(',').map((tag) => Number(tag)),
]),
];
this.tags = tags;
logger.info(`Request has override tags from override rules`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
tagIds: tags,
});
}
} }
if (sonarrSettings.tagRequests) { if (sonarrSettings.tagRequests) {
@@ -1101,6 +1301,8 @@ export class MediaRequest {
} }
} }
requestRepository.save(this);
const sonarrSeriesOptions: AddSeriesOptions = { const sonarrSeriesOptions: AddSeriesOptions = {
profileId: qualityProfile, profileId: qualityProfile,
languageProfileId: languageProfile, languageProfileId: languageProfile,
@@ -1137,8 +1339,6 @@ export class MediaRequest {
await mediaRepository.save(media); await mediaRepository.save(media);
}) })
.catch(async () => { .catch(async () => {
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED; this.status = MediaRequestStatus.FAILED;
requestRepository.save(this); requestRepository.save(this);

View File

@@ -0,0 +1,52 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity()
class OverrideRule {
@PrimaryGeneratedColumn()
public id: number;
@Column({ type: 'int', nullable: true })
public radarrServiceId?: number;
@Column({ type: 'int', nullable: true })
public sonarrServiceId?: number;
@Column({ nullable: true })
public users?: string;
@Column({ nullable: true })
public genre?: string;
@Column({ nullable: true })
public language?: string;
@Column({ nullable: true })
public keywords?: string;
@Column({ type: 'int', nullable: true })
public profileId?: number;
@Column({ nullable: true })
public rootFolder?: string;
@Column({ nullable: true })
public tags?: string;
@CreateDateColumn()
public createdAt: Date;
@UpdateDateColumn()
public updatedAt: Date;
constructor(init?: Partial<OverrideRule>) {
Object.assign(this, init);
}
}
export default OverrideRule;

View File

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

View File

@@ -0,0 +1,3 @@
import type OverrideRule from '@server/entity/OverrideRule';
export type OverrideRuleResultsResponse = OverrideRule[];

View File

@@ -135,6 +135,7 @@ 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,
@@ -142,6 +143,7 @@ class ImageProxy {
options: { options: {
cacheVersion?: number; cacheVersion?: number;
rateLimitOptions?: RateLimitOptions; rateLimitOptions?: RateLimitOptions;
headers?: HeadersInit;
} = {} } = {}
) { ) {
this.cacheVersion = options.cacheVersion ?? 1; this.cacheVersion = options.cacheVersion ?? 1;
@@ -155,9 +157,13 @@ class ImageProxy {
} else { } else {
this.fetch = fetch; this.fetch = fetch;
} }
this.headers = options.headers || null;
} }
public async getImage(path: string): Promise<ImageResponse> { public async getImage(
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);
@@ -166,7 +172,11 @@ class ImageProxy {
const newImage = await this.set(path, cacheKey); const newImage = await this.set(path, cacheKey);
if (!newImage) { if (!newImage) {
throw new Error('Failed to load image'); if (fallbackPath) {
return await this.getImage(fallbackPath);
} else {
throw new Error('Failed to load image');
}
} }
return newImage; return newImage;
@@ -247,7 +257,12 @@ 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

@@ -291,6 +291,10 @@ class DiscordAgent
} }
} }
if (settings.options.webhookRoleId) {
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
}
const response = await fetch(settings.options.webhookUrl, { const response = await fetch(settings.options.webhookUrl, {
method: 'POST', method: 'POST',
headers: { headers: {

View File

@@ -129,7 +129,7 @@ class PlexScanner
}); });
settings.plex.libraries = newLibraries; settings.plex.libraries = newLibraries;
settings.save(); await 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'; import fs from 'fs/promises';
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';
@@ -76,6 +76,7 @@ export interface DVRSettings {
syncEnabled: boolean; syncEnabled: boolean;
preventSearch: boolean; preventSearch: boolean;
tagRequests: boolean; tagRequests: boolean;
overrideRule: number[];
} }
export interface RadarrSettings extends DVRSettings { export interface RadarrSettings extends DVRSettings {
@@ -99,6 +100,17 @@ 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;
@@ -119,6 +131,7 @@ export interface MainSettings {
mediaServerType: number; mediaServerType: number;
partialRequestsEnabled: boolean; partialRequestsEnabled: boolean;
locale: string; locale: string;
proxy: ProxySettings;
} }
interface PublicSettings { interface PublicSettings {
@@ -158,6 +171,7 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig {
botUsername?: string; botUsername?: string;
botAvatarUrl?: string; botAvatarUrl?: string;
webhookUrl: string; webhookUrl: string;
webhookRoleId?: string;
enableMentions: boolean; enableMentions: boolean;
}; };
} }
@@ -325,6 +339,16 @@ class Settings {
mediaServerType: MediaServerType.NOT_CONFIGURED, mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true, partialRequestsEnabled: true,
locale: 'en', locale: 'en',
proxy: {
enabled: false,
hostname: '',
port: 8080,
useSsl: false,
user: '',
password: '',
bypassFilter: '',
bypassLocalAddresses: true,
},
}, },
plex: { plex: {
name: '', name: '',
@@ -372,6 +396,7 @@ class Settings {
types: 0, types: 0,
options: { options: {
webhookUrl: '', webhookUrl: '',
webhookRoleId: '',
enableMentions: true, enableMentions: true,
}, },
}, },
@@ -479,10 +504,6 @@ 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;
} }
@@ -584,29 +605,20 @@ 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 regenerateApiKey(): MainSettings { public async regenerateApiKey(): Promise<MainSettings> {
this.main.apiKey = this.generateApiKey(); this.main.apiKey = this.generateApiKey();
this.save(); await this.save();
return this.main; return this.main;
} }
@@ -618,15 +630,6 @@ 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,30 +644,51 @@ class Settings {
return this; return this;
} }
if (!fs.existsSync(SETTINGS_PATH)) { let data;
this.save(); try {
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);
this.data = await runMigrations(parsedJson, SETTINGS_PATH); const migratedData = await runMigrations(parsedJson, SETTINGS_PATH);
this.data = merge(this.data, migratedData);
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;
}
}
this.save();
} }
// 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;
}
}
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();
}
return this; return this;
} }
public save(): void { public async save(): Promise<void> {
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(this.data, undefined, ' ')); await fs.writeFile(
SETTINGS_PATH,
JSON.stringify(this.data, undefined, ' ')
);
} }
} }

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
/* 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'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
const migrationsDir = path.join(__dirname, 'migrations'); const migrationsDir = path.join(__dirname, 'migrations');
@@ -10,19 +9,56 @@ 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) {
migrated = await migration(migrated); 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();
}
} }
const settingsAfter = JSON.stringify(migrated); const settingsAfter = JSON.stringify(migrated);
@@ -30,30 +66,33 @@ 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
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(migrated, undefined, ' ')); await fs.writeFile(
const fileSaved = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')); SETTINGS_PATH,
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',
}
); );
// we stop jellyseerr if the migration failed logger.error(
console.log( 'A common cause for this issue is a permission error of your configuration folder.',
'====================================================================' {
); 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

@@ -0,0 +1,15 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddOverrideRules1731963944025 implements MigrationInterface {
name = 'AddOverrideRules1731963944025';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "override_rule"`);
}
}

View File

@@ -6,7 +6,6 @@ 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';
@@ -15,7 +14,6 @@ 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();
@@ -89,7 +87,7 @@ authRoutes.post('/plex', async (req, res, next) => {
}); });
settings.main.mediaServerType = MediaServerType.PLEX; settings.main.mediaServerType = MediaServerType.PLEX;
settings.save(); await settings.save();
startJobs(); startJobs();
await userRepository.save(user); await userRepository.save(user);
@@ -301,64 +299,84 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
where: { jellyfinUserId: account.User.Id }, where: { jellyfinUserId: account.User.Id },
}); });
if (!user && !(await userRepository.count())) { const missingAdminUser = !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);
} }
logger.info( if (
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr', body.serverType !== MediaServerType.JELLYFIN &&
{ body.serverType !== MediaServerType.EMBY
label: 'API', ) {
ip: req.ip, 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,
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;
// User doesn't exist, and there are no users in the database, we'll create the user await userRepository.save(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
@@ -378,10 +396,8 @@ 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;
settings.save(); await 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) {
@@ -401,27 +417,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUsername: account.User.Name, jellyfinUsername: account.User.Name,
} }
); );
// Update the users avatar with their jellyfin profile pic (incase it changed) user.avatar = `/avatarproxy/${account.User.Id}`;
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) {
@@ -459,12 +455,7 @@ 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: account.User.PrimaryImageTag avatar: `/avatarproxy/${account.User.Id}`,
? `/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,21 +1,39 @@
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();
const avatarImageProxy = new ImageProxy('avatar', ''); let _avatarImageProxy: ImageProxy | null = null;
// Proxy avatar images async function initAvatarImageProxy() {
router.get('/*', async (req, res) => { if (!_avatarImageProxy) {
let imagePath = ''; const userRepository = getRepository(User);
const admin = await userRepository.findOne({
where: { id: 1 },
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' },
});
const deviceId = admin?.jellyfinDeviceId;
const authToken = getSettings().jellyfin.apiKey;
_avatarImageProxy = new ImageProxy('avatar', '', {
headers: {
'X-Emby-Authorization': `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`,
},
});
}
return _avatarImageProxy;
}
router.get('/:jellyfinUserId', async (req, res) => {
try { try {
const jellyfinAvatar = req.url.match( if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) {
/(\/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 ${
@@ -26,10 +44,28 @@ router.get('/*', async (req, res) => {
); );
} }
const imageUrl = new URL(jellyfinAvatar, getHostname()); const avatarImageCache = await initAvatarImageProxy();
imagePath = imageUrl.toString();
const imageData = await avatarImageProxy.getImage(imagePath); const user = await getRepository(User).findOne({
where: { jellyfinUserId: req.params.jellyfinUserId },
});
const fallbackUrl = gravatarUrl(user?.email || 'none', {
default: 'mm',
size: 200,
});
const jellyfinAvatarUrl = `${getHostname()}/UserImage?UserId=${
req.params.jellyfinUserId
}`;
let imageData = await avatarImageCache.getImage(
jellyfinAvatarUrl,
fallbackUrl
);
if (imageData.meta.extension === 'json') {
// this is a 404
imageData = await avatarImageCache.getImage(fallbackUrl);
}
res.writeHead(200, { res.writeHead(200, {
'Content-Type': `image/${imageData.meta.extension}`, 'Content-Type': `image/${imageData.meta.extension}`,
@@ -42,7 +78,6 @@ router.get('/*', 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,14 +2,12 @@ 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 { EntityNotFoundError, QueryFailedError } from 'typeorm';
import { QueryFailedError } from 'typeorm';
import { z } from 'zod'; import { z } from 'zod';
const blacklistRoutes = Router(); const blacklistRoutes = Router();
@@ -26,7 +24,6 @@ blacklistRoutes.get(
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], { isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
type: 'or', type: 'or',
}), }),
rateLimit({ windowMs: 60 * 1000, max: 50 }),
async (req, res, next) => { async (req, res, next) => {
const pageSize = req.query.take ? Number(req.query.take) : 25; const pageSize = req.query.take ? Number(req.query.take) : 25;
const skip = req.query.skip ? Number(req.query.skip) : 0; const skip = req.query.skip ? Number(req.query.skip) : 0;
@@ -71,6 +68,32 @@ 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], {
@@ -134,7 +157,7 @@ blacklistRoutes.delete(
return res.status(204).send(); return res.status(204).send();
} catch (e) { } catch (e) {
if (e instanceof NotFoundError) { if (e instanceof EntityNotFoundError) {
return next({ return next({
status: 401, status: 401,
message: e.message, message: e.message,

View File

@@ -15,9 +15,14 @@ import { checkUser, isAuthenticated } from '@server/middleware/auth';
import { mapWatchProviderDetails } from '@server/models/common'; import { mapWatchProviderDetails } from '@server/models/common';
import { mapProductionCompany } from '@server/models/Movie'; import { mapProductionCompany } from '@server/models/Movie';
import { mapNetwork } from '@server/models/Tv'; import { mapNetwork } from '@server/models/Tv';
import overrideRuleRoutes from '@server/routes/overrideRule';
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 { appDataPath, appDataStatus } from '@server/utils/appDataVolume'; import {
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';
@@ -93,6 +98,7 @@ 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(),
}); });
}); });
@@ -155,6 +161,11 @@ router.use('/service', isAuthenticated(), serviceRoutes);
router.use('/issue', isAuthenticated(), issueRoutes); router.use('/issue', isAuthenticated(), issueRoutes);
router.use('/issueComment', isAuthenticated(), issueCommentRoutes); router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
router.use('/auth', authRoutes); router.use('/auth', authRoutes);
router.use(
'/overrideRule',
isAuthenticated(Permission.ADMIN),
overrideRuleRoutes
);
router.get('/regions', isAuthenticated(), async (req, res, next) => { router.get('/regions', isAuthenticated(), async (req, res, next) => {
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();

View File

@@ -0,0 +1,136 @@
import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule';
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
import { Permission } from '@server/lib/permissions';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
const overrideRuleRoutes = Router();
overrideRuleRoutes.get(
'/',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rules = await overrideRuleRepository.find({});
return res.status(200).json(rules as OverrideRuleResultsResponse);
} catch (e) {
next({ status: 404, message: e.message });
}
}
);
overrideRuleRoutes.post<
Record<string, string>,
OverrideRule,
{
users?: string;
genre?: string;
language?: string;
keywords?: string;
profileId?: number;
rootFolder?: string;
tags?: string;
radarrServiceId?: number;
sonarrServiceId?: number;
}
>('/', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rule = new OverrideRule({
users: req.body.users,
genre: req.body.genre,
language: req.body.language,
keywords: req.body.keywords,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
tags: req.body.tags,
radarrServiceId: req.body.radarrServiceId,
sonarrServiceId: req.body.sonarrServiceId,
});
const newRule = await overrideRuleRepository.save(rule);
return res.status(200).json(newRule);
} catch (e) {
next({ status: 404, message: e.message });
}
});
overrideRuleRoutes.put<
{ ruleId: string },
OverrideRule,
{
users?: string;
genre?: string;
language?: string;
keywords?: string;
profileId?: number;
rootFolder?: string;
tags?: string;
radarrServiceId?: number;
sonarrServiceId?: number;
}
>('/:ruleId', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rule = await overrideRuleRepository.findOne({
where: {
id: Number(req.params.ruleId),
},
});
if (!rule) {
return next({ status: 404, message: 'Override Rule not found.' });
}
rule.users = req.body.users;
rule.genre = req.body.genre;
rule.language = req.body.language;
rule.keywords = req.body.keywords;
rule.profileId = req.body.profileId;
rule.rootFolder = req.body.rootFolder;
rule.tags = req.body.tags;
rule.radarrServiceId = req.body.radarrServiceId;
rule.sonarrServiceId = req.body.sonarrServiceId;
const newRule = await overrideRuleRepository.save(rule);
return res.status(200).json(newRule);
} catch (e) {
next({ status: 404, message: e.message });
}
});
overrideRuleRoutes.delete<{ ruleId: string }, OverrideRule>(
'/:ruleId',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rule = await overrideRuleRepository.findOne({
where: {
id: Number(req.params.ruleId),
},
});
if (!rule) {
return next({ status: 404, message: 'Override Rule not found.' });
}
await overrideRuleRepository.remove(rule);
return res.status(200).json(rule);
} catch (e) {
next({ status: 404, message: e.message });
}
}
);
export default overrideRuleRoutes;

View File

@@ -123,9 +123,13 @@ 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 = await sonarr.getLanguageProfiles(); const languageProfiles =
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,7 +32,6 @@ 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';
@@ -70,19 +69,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', (req, res) => { settingsRoutes.post('/main', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.main = merge(settings.main, req.body); settings.main = merge(settings.main, req.body);
settings.save(); await settings.save();
return res.status(200).json(settings.main); return res.status(200).json(settings.main);
}); });
settingsRoutes.post('/main/regenerate', (req, res, next) => { settingsRoutes.post('/main/regenerate', async (req, res, next) => {
const settings = getSettings(); const settings = getSettings();
const main = settings.regenerateApiKey(); const main = await 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.' });
@@ -119,7 +118,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;
settings.save(); await 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',
@@ -232,7 +231,7 @@ settingsRoutes.get('/plex/library', async (req, res) => {
...library, ...library,
enabled: enabledLibraries.includes(library.id), enabled: enabledLibraries.includes(library.id),
})); }));
settings.save(); await settings.save();
return res.status(200).json(settings.plex.libraries); return res.status(200).json(settings.plex.libraries);
}); });
@@ -283,7 +282,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;
settings.save(); await 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', {
@@ -371,7 +370,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
...library, ...library,
enabled: enabledLibraries.includes(library.id), enabled: enabledLibraries.includes(library.id),
})); }));
settings.save(); await settings.save();
return res.status(200).json(settings.jellyfin.libraries); return res.status(200).json(settings.jellyfin.libraries);
}); });
@@ -395,9 +394,7 @@ 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: user.PrimaryImageTag thumb: `/avatarproxy/${user.Id}`,
? `/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
: gravatarUrl(user.Name, { default: 'mm', size: 200 }),
email: user.Name, email: user.Name,
})); }));
@@ -437,7 +434,7 @@ settingsRoutes.post('/tautulli', async (req, res, next) => {
throw new Error('Tautulli version not supported'); throw new Error('Tautulli version not supported');
} }
settings.save(); await 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',
@@ -698,7 +695,7 @@ settingsRoutes.post<{ jobId: JobId }>(
settingsRoutes.post<{ jobId: JobId }>( settingsRoutes.post<{ jobId: JobId }>(
'/jobs/:jobId/schedule', '/jobs/:jobId/schedule',
(req, res, next) => { async (req, res, next) => {
const scheduledJob = scheduledJobs.find( const scheduledJob = scheduledJobs.find(
(job) => job.id === req.params.jobId (job) => job.id === req.params.jobId
); );
@@ -712,7 +709,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;
settings.save(); await settings.save();
scheduledJob.cronSchedule = req.body.schedule; scheduledJob.cronSchedule = req.body.schedule;
@@ -769,11 +766,11 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
settingsRoutes.post( settingsRoutes.post(
'/initialize', '/initialize',
isAuthenticated(Permission.ADMIN), isAuthenticated(Permission.ADMIN),
(_req, res) => { async (_req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.public.initialized = true; settings.public.initialized = true;
settings.save(); await 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', (req, res) => { notificationRoutes.post('/discord', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.discord = req.body; settings.notifications.agents.discord = req.body;
settings.save(); await 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', (req, res) => { notificationRoutes.post('/slack', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.slack = req.body; settings.notifications.agents.slack = req.body;
settings.save(); await 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', (req, res) => { notificationRoutes.post('/telegram', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.telegram = req.body; settings.notifications.agents.telegram = req.body;
settings.save(); await 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', (req, res) => { notificationRoutes.post('/pushbullet', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.pushbullet = req.body; settings.notifications.agents.pushbullet = req.body;
settings.save(); await 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', (req, res) => { notificationRoutes.post('/pushover', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.pushover = req.body; settings.notifications.agents.pushover = req.body;
settings.save(); await 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', (req, res) => { notificationRoutes.post('/email', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.email = req.body; settings.notifications.agents.email = req.body;
settings.save(); await 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', (req, res) => { notificationRoutes.post('/webpush', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.webpush = req.body; settings.notifications.agents.webpush = req.body;
settings.save(); await 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', (req, res, next) => { notificationRoutes.post('/webhook', async (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', (req, res, next) => {
authHeader: req.body.options.authHeader, authHeader: req.body.options.authHeader,
}, },
}; };
settings.save(); await 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', (req, res) => { notificationRoutes.post('/lunasea', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.lunasea = req.body; settings.notifications.agents.lunasea = req.body;
settings.save(); await 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', (req, res) => { notificationRoutes.post('/gotify', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.gotify = req.body; settings.notifications.agents.gotify = req.body;
settings.save(); await 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('/', (req, res) => { radarrRoutes.post('/', async (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('/', (req, res) => {
} }
settings.radarr = [...settings.radarr, newRadarr]; settings.radarr = [...settings.radarr, newRadarr];
settings.save(); await 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',
(req, res, next) => { async (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;
settings.save(); await 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', (req, res, next) => { radarrRoutes.delete<{ id: string }>('/:id', async (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', (req, res, next) => {
} }
const removed = settings.radarr.splice(radarrIndex, 1); const removed = settings.radarr.splice(radarrIndex, 1);
settings.save(); await 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('/', (req, res) => { sonarrRoutes.post('/', async (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('/', (req, res) => {
} }
settings.sonarr = [...settings.sonarr, newSonarr]; settings.sonarr = [...settings.sonarr, newSonarr];
settings.save(); await settings.save();
return res.status(201).json(newSonarr); return res.status(201).json(newSonarr);
}); });
@@ -43,13 +43,14 @@ sonarrRoutes.post('/test', async (req, res, next) => {
url: SonarrAPI.buildUrl(req.body, '/api/v3'), url: SonarrAPI.buildUrl(req.body, '/api/v3'),
}); });
const urlBase = await sonarr const systemStatus = await sonarr.getSystemStatus();
.getSystemStatus() const sonarrMajorVersion = Number(systemStatus.version.split('.')[0]);
.then((value) => value.urlBase)
.catch(() => req.body.baseUrl); const urlBase = systemStatus.urlBase;
const profiles = await sonarr.getProfiles(); const profiles = await sonarr.getProfiles();
const folders = await sonarr.getRootFolders(); const folders = await sonarr.getRootFolders();
const languageProfiles = await sonarr.getLanguageProfiles(); const languageProfiles =
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({
@@ -72,7 +73,7 @@ sonarrRoutes.post('/test', async (req, res, next) => {
} }
}); });
sonarrRoutes.put<{ id: string }>('/:id', (req, res) => { sonarrRoutes.put<{ id: string }>('/:id', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex( const sonarrIndex = settings.sonarr.findIndex(
@@ -100,12 +101,12 @@ sonarrRoutes.put<{ id: string }>('/:id', (req, res) => {
...req.body, ...req.body,
id: Number(req.params.id), id: Number(req.params.id),
} as SonarrSettings; } as SonarrSettings;
settings.save(); await settings.save();
return res.status(200).json(settings.sonarr[sonarrIndex]); return res.status(200).json(settings.sonarr[sonarrIndex]);
}); });
sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => { sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex( const sonarrIndex = settings.sonarr.findIndex(
@@ -119,7 +120,7 @@ sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
} }
const removed = settings.sonarr.splice(sonarrIndex, 1); const removed = settings.sonarr.splice(sonarrIndex, 1);
settings.save(); await settings.save();
return res.status(200).json(removed[0]); return res.status(200).json(removed[0]);
}); });

View File

@@ -34,8 +34,16 @@ router.get('/', async (req, res, next) => {
try { try {
const pageSize = req.query.take ? Number(req.query.take) : 10; const pageSize = req.query.take ? Number(req.query.take) : 10;
const skip = req.query.skip ? Number(req.query.skip) : 0; const skip = req.query.skip ? Number(req.query.skip) : 0;
const q = req.query.q ? req.query.q.toString().toLowerCase() : '';
let query = getRepository(User).createQueryBuilder('user'); let query = getRepository(User).createQueryBuilder('user');
if (q) {
query = query.where(
'LOWER(user.username) LIKE :q OR LOWER(user.email) LIKE :q OR LOWER(user.plexUsername) LIKE :q OR LOWER(user.jellyfinUsername) LIKE :q',
{ q: `%${q}%` }
);
}
switch (req.query.sort) { switch (req.query.sort) {
case 'updated': case 'updated':
query = query.orderBy('user.updatedAt', 'DESC'); query = query.orderBy('user.updatedAt', 'DESC');
@@ -539,12 +547,7 @@ router.post(
).toString('base64'), ).toString('base64'),
email: jellyfinUser?.Name, email: jellyfinUser?.Name,
permissions: settings.main.defaultPermissions, permissions: settings.main.defaultPermissions,
avatar: jellyfinUser?.PrimaryImageTag avatar: `/avatarproxy/${jellyfinUser?.Id}`,
? `/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,5 @@
import { ApiErrorCode } from '@server/constants/error'; import { ApiErrorCode } from '@server/constants/error';
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 { UserSettings } from '@server/entity/UserSettings'; import { UserSettings } from '@server/entity/UserSettings';
@@ -99,11 +100,29 @@ userSettingsRoutes.post<
}); });
} }
user.username = req.body.username;
const oldEmail = user.email; const oldEmail = user.email;
const oldUsername = user.username;
user.username = req.body.username;
if (user.jellyfinUsername) { if (user.jellyfinUsername) {
user.email = req.body.email || user.jellyfinUsername || user.email; user.email = req.body.email || user.jellyfinUsername || user.email;
} }
// Edge case for local users, because they have no Jellyfin username to fall back on
// if the email is not provided
if (user.userType === UserType.LOCAL) {
if (req.body.email) {
user.email = req.body.email;
if (
!user.username &&
user.email !== oldEmail &&
!oldEmail.includes('@')
) {
user.username = oldEmail;
}
} else if (req.body.username) {
user.email = oldUsername || user.email;
user.username = req.body.username;
}
}
const existingUser = await userRepository.findOne({ const existingUser = await userRepository.findOne({
where: { email: user.email }, where: { email: user.email },

View File

@@ -1,4 +1,4 @@
import { existsSync } from 'fs'; import { accessSync, 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,3 +14,12 @@ 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

@@ -0,0 +1,111 @@
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,7 +13,8 @@ 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
); );
} }
} }

View File

@@ -1,5 +1,6 @@
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';
@@ -10,6 +11,7 @@ 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',
@@ -17,13 +19,13 @@ const messages = defineMessages('component.BlacklistBlock', {
}); });
interface BlacklistBlockProps { interface BlacklistBlockProps {
blacklistItem: Blacklist; tmdbId: number;
onUpdate?: () => void; onUpdate?: () => void;
onDelete?: () => void; onDelete?: () => void;
} }
const BlacklistBlock = ({ const BlacklistBlock = ({
blacklistItem, tmdbId,
onUpdate, onUpdate,
onDelete, onDelete,
}: BlacklistBlockProps) => { }: BlacklistBlockProps) => {
@@ -31,6 +33,7 @@ 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);
@@ -62,6 +65,14 @@ 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">
@@ -73,13 +84,13 @@ const BlacklistBlock = ({
<span className="w-40 truncate md:w-auto"> <span className="w-40 truncate md:w-auto">
<Link <Link
href={ href={
blacklistItem.user.id === user?.id data.user.id === user?.id
? '/profile' ? '/profile'
: `/users/${blacklistItem.user.id}` : `/users/${data.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">
{blacklistItem.user.displayName} {data.user.displayName}
</span> </span>
</Link> </Link>
</span> </span>
@@ -91,9 +102,7 @@ const BlacklistBlock = ({
> >
<Button <Button
buttonType="danger" buttonType="danger"
onClick={() => onClick={() => removeFromBlacklist(data.tmdbId, data.title)}
removeFromBlacklist(blacklistItem.tmdbId, blacklistItem.title)
}
disabled={isUpdating} disabled={isUpdating}
> >
<TrashIcon className="icon-sm" /> <TrashIcon className="icon-sm" />
@@ -114,7 +123,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(blacklistItem.createdAt, { {intl.formatDate(data.createdAt, {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',

View File

@@ -38,7 +38,7 @@ const BlacklistModal = ({
const intl = useIntl(); const intl = useIntl();
const { data, error } = useSWR<TvDetails | MovieDetails>( const { data, error } = useSWR<TvDetails | MovieDetails>(
`/api/v1/${type}/${tmdbId}` show ? `/api/v1/${type}/${tmdbId}` : null
); );
return ( return (

View File

@@ -25,11 +25,8 @@ 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 (in any) // jellyfin avatar (if any)
const jellyfinAvatar = src.match( imageUrl = src;
/(\/Users\/\w+\/Images\/Primary\/?\?tag=\w+&quality=90)$/
)?.[1];
imageUrl = jellyfinAvatar ? `/avatarproxy` + jellyfinAvatar : src;
} else { } else {
return null; return null;
} }

View File

@@ -7,7 +7,7 @@ import { useLockBodyScroll } from '@app/hooks/useLockBodyScroll';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import type { MouseEvent } from 'react'; import type { MouseEvent } from 'react';
import React, { Fragment, useRef } from 'react'; import React, { Fragment, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -66,8 +66,12 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
) => { ) => {
const intl = useIntl(); const intl = useIntl();
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const backgroundClickableRef = useRef(backgroundClickable); // This ref is used to detect state change inside the useClickOutside hook
useEffect(() => {
backgroundClickableRef.current = backgroundClickable;
}, [backgroundClickable]);
useClickOutside(modalRef, () => { useClickOutside(modalRef, () => {
if (onCancel && backgroundClickable) { if (onCancel && backgroundClickableRef.current) {
onCancel(); onCancel();
} }
}); });

View File

@@ -7,7 +7,9 @@ import defineMessages from '@app/utils/defineMessages';
import type { TvResult } from '@server/models/Search'; import type { TvResult } from '@server/models/Search';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages('components.DiscoverTvUpcoming', {}); const messages = defineMessages('components.DiscoverTvUpcoming', {
upcomingtv: 'Upcoming Series',
});
const DiscoverTvUpcoming = () => { const DiscoverTvUpcoming = () => {
const intl = useIntl(); const intl = useIntl();

View File

@@ -82,10 +82,17 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
port: Yup.number().required( port: Yup.number().required(
intl.formatMessage(messages.validationPortRequired) intl.formatMessage(messages.validationPortRequired)
), ),
urlBase: Yup.string().matches( urlBase: Yup.string()
/^(.*[^/])$/, .test(
intl.formatMessage(messages.validationUrlBaseTrailingSlash) 'leading-slash',
), intl.formatMessage(messages.validationUrlBaseLeadingSlash),
(value) => !value || value.startsWith('/')
)
.test(
'trailing-slash',
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
(value) => !value || !value.endsWith('/')
),
email: Yup.string() email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat)) .email(intl.formatMessage(messages.validationemailformat))
.required(intl.formatMessage(messages.validationemailrequired)), .required(intl.formatMessage(messages.validationemailrequired)),

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
blacklistItem={data.mediaInfo.blacklist} tmdbId={data.mediaInfo.tmdbId}
onUpdate={() => revalidate()} onUpdate={() => revalidate()}
onDelete={() => onClose()} onDelete={() => onClose()}
/> />

View File

@@ -88,14 +88,14 @@ const SearchByNameModal = ({
tvdbId === item.tvdbId ? 'ring ring-indigo-500' : '' tvdbId === item.tvdbId ? 'ring ring-indigo-500' : ''
} `} } `}
> >
<div className="flex w-24 flex-none items-center space-x-4"> <div className="relative flex w-24 flex-none items-center space-x-4 self-stretch">
<Image <Image
src={ src={
item.remotePoster ?? item.remotePoster ??
'/images/overseerr_poster_not_found.png' '/images/overseerr_poster_not_found.png'
} }
alt={item.title} alt={item.title}
className="h-100 w-auto rounded-md" className="w-100 h-auto rounded-md"
fill fill
/> />
</div> </div>

View File

@@ -13,6 +13,7 @@ import type {
TmdbKeywordSearchResponse, TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces'; } from '@server/api/themoviedb/interfaces';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import type { import type {
Keyword, Keyword,
ProductionCompany, ProductionCompany,
@@ -29,6 +30,7 @@ const messages = defineMessages('components.Selector', {
searchKeywords: 'Search keywords…', searchKeywords: 'Search keywords…',
searchGenres: 'Select genres…', searchGenres: 'Select genres…',
searchStudios: 'Search studios…', searchStudios: 'Search studios…',
searchUsers: 'Select users…',
starttyping: 'Starting typing to search.', starttyping: 'Starting typing to search.',
nooptions: 'No results.', nooptions: 'No results.',
showmore: 'Show More', showmore: 'Show More',
@@ -437,7 +439,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`} key={`prodiver-${provider.id}`}
> >
<div <div
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 ${ className={`provider-container w-full cursor-pointer rounded-lg ring-1 ${
isActive isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500' ? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600' : 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
@@ -451,18 +453,15 @@ export const WatchProviderSelector = ({
role="button" role="button"
tabIndex={0} tabIndex={0}
> >
<CachedImage <div className="relative m-2 aspect-1">
type="tmdb" <CachedImage
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`} type="tmdb"
alt="" src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
style={{ alt=""
width: '100%', fill
height: '100%', className="rounded-lg object-contain"
objectFit: 'contain', />
}} </div>
fill
className="rounded-lg"
/>
{isActive && ( {isActive && (
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90"> <div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
<CheckCircleIcon className="h-6 w-6" /> <CheckCircleIcon className="h-6 w-6" />
@@ -483,7 +482,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`} key={`prodiver-${provider.id}`}
> >
<div <div
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 transition ${ className={`provider-container w-full cursor-pointer rounded-lg ring-1 transition ${
isActive isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500' ? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600' : 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
@@ -497,18 +496,15 @@ export const WatchProviderSelector = ({
role="button" role="button"
tabIndex={0} tabIndex={0}
> >
<CachedImage <div className="relative m-2 aspect-1">
type="tmdb" <CachedImage
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`} type="tmdb"
alt="" src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
style={{ alt=""
width: '100%', fill
height: '100%', className="rounded-lg object-contain"
objectFit: 'cover', />
}} </div>
fill
className="rounded-lg"
/>
{isActive && ( {isActive && (
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90"> <div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
<CheckCircleIcon className="h-6 w-6" /> <CheckCircleIcon className="h-6 w-6" />
@@ -548,3 +544,77 @@ export const WatchProviderSelector = ({
</> </>
); );
}; };
export const UserSelector = ({
isMulti,
defaultValue,
onChange,
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
const intl = useIntl();
const [defaultDataValue, setDefaultDataValue] = useState<
{ label: string; value: number }[] | null
>(null);
useEffect(() => {
const loadUsers = async (): Promise<void> => {
if (!defaultValue) {
return;
}
const users = defaultValue.split(',');
const res = await fetch(`/api/v1/user`);
if (!res.ok) {
throw new Error('Network response was not ok');
}
const response: UserResultsResponse = await res.json();
const genreData = users
.filter((u) => response.results.find((user) => user.id === Number(u)))
.map((u) => response.results.find((user) => user.id === Number(u)))
.map((u) => ({
label: u?.displayName ?? '',
value: u?.id ?? 0,
}));
setDefaultDataValue(genreData);
};
loadUsers();
}, [defaultValue]);
const loadUserOptions = async (inputValue: string) => {
const res = await fetch(
`/api/v1/user${inputValue ? `?q=${encodeURIComponent(inputValue)}` : ''}`
);
if (!res.ok) throw new Error();
const results: UserResultsResponse = await res.json();
return results.results
.map((result) => ({
label: result.displayName,
value: result.id,
}))
.filter(({ label }) =>
label.toLowerCase().includes(inputValue.toLowerCase())
);
};
return (
<AsyncSelect
key={`user-select-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]}
defaultOptions
cacheOptions
isMulti={isMulti}
loadOptions={loadUserOptions}
placeholder={intl.formatMessage(messages.searchUsers)}
onChange={(value) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange(value as any);
}}
/>
);
};

View File

@@ -19,12 +19,16 @@ const messages = defineMessages('components.Settings.Notifications', {
webhookUrl: 'Webhook URL', webhookUrl: 'Webhook URL',
webhookUrlTip: webhookUrlTip:
'Create a <DiscordWebhookLink>webhook integration</DiscordWebhookLink> in your server', 'Create a <DiscordWebhookLink>webhook integration</DiscordWebhookLink> in your server',
webhookRoleId: 'Notification Role ID',
webhookRoleIdTip:
'The role ID to mention in the webhook message. Leave empty to disable mentions',
discordsettingssaved: 'Discord notification settings saved successfully!', discordsettingssaved: 'Discord notification settings saved successfully!',
discordsettingsfailed: 'Discord notification settings failed to save.', discordsettingsfailed: 'Discord notification settings failed to save.',
toastDiscordTestSending: 'Sending Discord test notification…', toastDiscordTestSending: 'Sending Discord test notification…',
toastDiscordTestSuccess: 'Discord test notification sent!', toastDiscordTestSuccess: 'Discord test notification sent!',
toastDiscordTestFailed: 'Discord test notification failed to send.', toastDiscordTestFailed: 'Discord test notification failed to send.',
validationUrl: 'You must provide a valid URL', validationUrl: 'You must provide a valid URL',
validationWebhookRoleId: 'You must provide a valid Discord Role ID',
validationTypes: 'You must select at least one notification type', validationTypes: 'You must select at least one notification type',
enableMentions: 'Enable Mentions', enableMentions: 'Enable Mentions',
}); });
@@ -53,6 +57,12 @@ const NotificationsDiscord = () => {
otherwise: Yup.string().nullable(), otherwise: Yup.string().nullable(),
}) })
.url(intl.formatMessage(messages.validationUrl)), .url(intl.formatMessage(messages.validationUrl)),
webhookRoleId: Yup.string()
.nullable()
.matches(
/^\d{17,19}$/,
intl.formatMessage(messages.validationWebhookRoleId)
),
}); });
if (!data && !error) { if (!data && !error) {
@@ -67,6 +77,7 @@ const NotificationsDiscord = () => {
botUsername: data?.options.botUsername, botUsername: data?.options.botUsername,
botAvatarUrl: data?.options.botAvatarUrl, botAvatarUrl: data?.options.botAvatarUrl,
webhookUrl: data.options.webhookUrl, webhookUrl: data.options.webhookUrl,
webhookRoleId: data?.options.webhookRoleId,
enableMentions: data?.options.enableMentions, enableMentions: data?.options.enableMentions,
}} }}
validationSchema={NotificationsDiscordSchema} validationSchema={NotificationsDiscordSchema}
@@ -84,6 +95,7 @@ const NotificationsDiscord = () => {
botUsername: values.botUsername, botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl, botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl, webhookUrl: values.webhookUrl,
webhookRoleId: values.webhookRoleId,
enableMentions: values.enableMentions, enableMentions: values.enableMentions,
}, },
}), }),
@@ -141,6 +153,7 @@ const NotificationsDiscord = () => {
botUsername: values.botUsername, botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl, botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl, webhookUrl: values.webhookUrl,
webhookRoleId: values.webhookRoleId,
enableMentions: values.enableMentions, enableMentions: values.enableMentions,
}, },
}), }),
@@ -254,6 +267,21 @@ const NotificationsDiscord = () => {
)} )}
</div> </div>
</div> </div>
<div className="form-row">
<label htmlFor="webhookRoleId" className="text-label">
{intl.formatMessage(messages.webhookRoleId)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="webhookRoleId" name="webhookRoleId" type="text" />
</div>
{errors.webhookRoleId &&
touched.webhookRoleId &&
typeof errors.webhookRoleId === 'string' && (
<div className="error">{errors.webhookRoleId}</div>
)}
</div>
</div>
<div className="form-row"> <div className="form-row">
<label htmlFor="enableMentions" className="checkbox-label"> <label htmlFor="enableMentions" className="checkbox-label">
{intl.formatMessage(messages.enableMentions)} {intl.formatMessage(messages.enableMentions)}

View File

@@ -0,0 +1,391 @@
import Modal from '@app/components/Common/Modal';
import LanguageSelector from '@app/components/LanguageSelector';
import {
GenreSelector,
KeywordSelector,
UserSelector,
} from '@app/components/Selector';
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import type OverrideRule from '@server/entity/OverrideRule';
import { Field, Formik } from 'formik';
import { useIntl } from 'react-intl';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages('components.Settings.RadarrModal', {
createrule: 'New Override Rule',
editrule: 'Edit Override Rule',
create: 'Create rule',
conditions: 'Conditions',
conditionsDescription:
'Specifies conditions before applying parameter changes. Each field must be validated for the rules to be applied (AND operation). A field is considered verified if any of its properties match (OR operation).',
settings: 'Settings',
settingsDescription:
'Specifies which settings will be changed when the above conditions are met.',
users: 'Users',
genres: 'Genres',
languages: 'Languages',
keywords: 'Keywords',
rootfolder: 'Root Folder',
selectRootFolder: 'Select root folder',
qualityprofile: 'Quality Profile',
selectQualityProfile: 'Select quality profile',
tags: 'Tags',
notagoptions: 'No tags.',
selecttags: 'Select tags',
ruleCreated: 'Override rule created successfully!',
ruleUpdated: 'Override rule updated successfully!',
});
type OptionType = {
value: number;
label: string;
};
interface OverrideRuleModalProps {
rule: OverrideRule | null;
onClose: () => void;
testResponse: DVRTestResponse;
radarrId?: number;
sonarrId?: number;
}
const OverrideRuleModal = ({
onClose,
rule,
testResponse,
radarrId,
sonarrId,
}: OverrideRuleModalProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const { currentSettings } = useSettings();
return (
<Transition
as="div"
appear
show
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Formik
initialValues={{
users: rule?.users,
genre: rule?.genre,
language: rule?.language,
keywords: rule?.keywords,
profileId: rule?.profileId,
rootFolder: rule?.rootFolder,
tags: rule?.tags,
}}
onSubmit={async (values) => {
try {
const submission = {
users: values.users || null,
genre: values.genre || null,
language: values.language || null,
keywords: values.keywords || null,
profileId: Number(values.profileId) || null,
rootFolder: values.rootFolder || null,
tags: values.tags || null,
radarrServiceId: radarrId,
sonarrServiceId: sonarrId,
};
if (!rule) {
const res = await fetch('/api/v1/overrideRule', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(submission),
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.ruleCreated), {
appearance: 'success',
autoDismiss: true,
});
} else {
const res = await fetch(`/api/v1/overrideRule/${rule.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(submission),
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.ruleUpdated), {
appearance: 'success',
autoDismiss: true,
});
}
onClose();
} catch (e) {
// set error here
}
}}
>
{({
errors,
touched,
values,
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
}) => {
return (
<Modal
onCancel={onClose}
okButtonType="primary"
okText={
isSubmitting
? intl.formatMessage(globalMessages.saving)
: rule
? intl.formatMessage(globalMessages.save)
: intl.formatMessage(messages.create)
}
okDisabled={
isSubmitting ||
!isValid ||
(!values.users &&
!values.genre &&
!values.language &&
!values.keywords) ||
(!values.rootFolder && !values.profileId && !values.tags)
}
onOk={() => handleSubmit()}
title={
!rule
? intl.formatMessage(messages.createrule)
: intl.formatMessage(messages.editrule)
}
>
<div className="mb-6">
<h3 className="text-lg font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.conditions)}
</h3>
<p className="description">
{intl.formatMessage(messages.conditionsDescription)}
</p>
<div className="form-row">
<label htmlFor="users" className="text-label">
{intl.formatMessage(messages.users)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<UserSelector
defaultValue={values.users}
isMulti
onChange={(users) => {
setFieldValue(
'users',
users?.map((v) => v.value).join(',')
);
}}
/>
</div>
{errors.users &&
touched.users &&
typeof errors.users === 'string' && (
<div className="error">{errors.users}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="genre" className="text-label">
{intl.formatMessage(messages.genres)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<GenreSelector
type={radarrId ? 'movie' : 'tv'}
defaultValue={values.genre}
isMulti
onChange={(genres) => {
setFieldValue(
'genre',
genres?.map((v) => v.value).join(',')
);
}}
/>
</div>
{errors.genre &&
touched.genre &&
typeof errors.genre === 'string' && (
<div className="error">{errors.genre}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="language" className="text-label">
{intl.formatMessage(messages.languages)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<LanguageSelector
value={values.language}
serverValue={currentSettings.originalLanguage}
setFieldValue={(_key, value) => {
setFieldValue('language', value);
}}
/>
</div>
{errors.language &&
touched.language &&
typeof errors.language === 'string' && (
<div className="error">{errors.language}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="keywords" className="text-label">
{intl.formatMessage(messages.keywords)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<KeywordSelector
defaultValue={values.keywords}
isMulti
onChange={(value) => {
setFieldValue(
'keywords',
value?.map((v) => v.value).join(',')
);
}}
/>
</div>
{errors.keywords &&
touched.keywords &&
typeof errors.keywords === 'string' && (
<div className="error">{errors.keywords}</div>
)}
</div>
</div>
<h3 className="mt-4 text-lg font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.settings)}
</h3>
<p className="description">
{intl.formatMessage(messages.settingsDescription)}
</p>
<div className="form-row">
<label htmlFor="rootFolderRule" className="text-label">
{intl.formatMessage(messages.rootfolder)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field as="select" id="rootFolderRule" name="rootFolder">
<option value="">
{intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.length > 0 &&
testResponse.rootFolders.map((folder) => (
<option
key={`loaded-profile-${folder.id}`}
value={folder.path}
>
{folder.path}
</option>
))}
</Field>
</div>
{errors.rootFolder &&
touched.rootFolder &&
typeof errors.rootFolder === 'string' && (
<div className="error">{errors.rootFolder}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="profileIdRule" className="text-label">
{intl.formatMessage(messages.qualityprofile)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field as="select" id="profileIdRule" name="profileId">
<option value="">
{intl.formatMessage(messages.selectQualityProfile)}
</option>
{testResponse.profiles.length > 0 &&
testResponse.profiles.map((profile) => (
<option
key={`loaded-profile-${profile.id}`}
value={profile.id}
>
{profile.name}
</option>
))}
</Field>
</div>
{errors.profileId &&
touched.profileId &&
typeof errors.profileId === 'string' && (
<div className="error">{errors.profileId}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
</label>
<div className="form-input-area">
<Select<OptionType, true>
options={testResponse.tags.map((tag) => ({
label: tag.label,
value: tag.id,
}))}
isMulti
placeholder={intl.formatMessage(messages.selecttags)}
className="react-select-container"
classNamePrefix="react-select"
value={
(values?.tags
?.split(',')
.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === Number(tagId)
);
if (!foundTag) {
return undefined;
}
return {
value: foundTag.id,
label: foundTag.label,
};
})
.filter(
(option) => option !== undefined
) as OptionType[]) || []
}
onChange={(value) => {
setFieldValue(
'tags',
value.map((option) => option.value).join(',')
);
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
}
/>
</div>
</div>
</div>
</Modal>
);
}}
</Formik>
</Transition>
);
};
export default OverrideRuleModal;

View File

@@ -0,0 +1,267 @@
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { PencilIcon, TrashIcon } from '@heroicons/react/24/solid';
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
import type OverrideRule from '@server/entity/OverrideRule';
import type { User } from '@server/entity/User';
import type {
Language,
RadarrSettings,
SonarrSettings,
} from '@server/lib/settings';
import type { Keyword } from '@server/models/common';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages('components.Settings.OverrideRuleTile', {
qualityprofile: 'Quality Profile',
rootfolder: 'Root Folder',
tags: 'Tags',
users: 'Users',
genre: 'Genre',
language: 'Language',
keywords: 'Keywords',
conditions: 'Conditions',
settings: 'Settings',
});
interface OverrideRuleTileProps {
rules: OverrideRule[];
setOverrideRuleModal: ({
open,
rule,
testResponse,
}: {
open: boolean;
rule: OverrideRule | null;
testResponse: DVRTestResponse;
}) => void;
testResponse: DVRTestResponse;
radarr?: RadarrSettings | null;
sonarr?: SonarrSettings | null;
revalidate: () => void;
}
const OverrideRuleTile = ({
rules,
setOverrideRuleModal,
testResponse,
radarr,
sonarr,
revalidate,
}: OverrideRuleTileProps) => {
const intl = useIntl();
const [users, setUsers] = useState<User[] | null>(null);
const [keywords, setKeywords] = useState<Keyword[] | null>(null);
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
const { data: genres } = useSWR<TmdbGenre[]>('/api/v1/genres/movie');
useEffect(() => {
(async () => {
const keywords = await Promise.all(
rules
.map((rule) => rule.keywords?.split(','))
.flat()
.filter((keywordId) => keywordId)
.map(async (keywordId) => {
const res = await fetch(`/api/v1/keyword/${keywordId}`);
if (!res.ok) throw new Error();
const keyword: Keyword = await res.json();
return keyword;
})
);
setKeywords(keywords);
const users = await Promise.all(
rules
.map((rule) => rule.users?.split(','))
.flat()
.filter((userId) => userId)
.map(async (userId) => {
const res = await fetch(`/api/v1/user/${userId}`);
if (!res.ok) throw new Error();
const user: User = await res.json();
return user;
})
);
setUsers(users);
})();
}, [rules]);
return (
<>
{rules
.filter(
(rule) =>
(rule.radarrServiceId !== null &&
rule.radarrServiceId === radarr?.id) ||
(rule.sonarrServiceId !== null &&
rule.sonarrServiceId === sonarr?.id)
)
.map((rule) => (
<li className="flex h-full flex-col rounded-lg bg-gray-800 text-left shadow ring-1 ring-gray-500">
<div className="flex w-full flex-1 items-center justify-between space-x-6 p-6">
<div className="flex-1 truncate">
<span className="text-lg">
{intl.formatMessage(messages.conditions)}
</span>
{rule.users && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.users)}
</span>
<div className="inline-flex gap-2">
{rule.users.split(',').map((userId) => {
return (
<span>
{
users?.find((user) => user.id === Number(userId))
?.displayName
}
</span>
);
})}
</div>
</p>
)}
{rule.genre && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.genre)}
</span>
<div className="inline-flex gap-2">
{rule.genre.split(',').map((genreId) => (
<span>
{genres?.find((g) => g.id === Number(genreId))?.name}
</span>
))}
</div>
</p>
)}
{rule.language && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.language)}
</span>
<div className="inline-flex gap-2">
{rule.language
.split('|')
.filter((languageId) => languageId !== 'server')
.map((languageId) => {
const language = languages?.find(
(language) => language.iso_639_1 === languageId
);
if (!language) return null;
const languageName =
intl.formatDisplayName(language.iso_639_1, {
type: 'language',
fallback: 'none',
}) ?? language.english_name;
return <span>{languageName}</span>;
})}
</div>
</p>
)}
{rule.keywords && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.keywords)}
</span>
<div className="inline-flex gap-2">
{rule.keywords.split(',').map((keywordId) => {
return (
<span>
{
keywords?.find(
(keyword) => keyword.id === Number(keywordId)
)?.name
}
</span>
);
})}
</div>
</p>
)}
<span className="text-lg">
{intl.formatMessage(messages.settings)}
</span>
{rule.profileId && (
<p className="runcate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.qualityprofile)}
</span>
{
testResponse.profiles.find(
(profile) => rule.profileId === profile.id
)?.name
}
</p>
)}
{rule.rootFolder && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.rootfolder)}
</span>
{rule.rootFolder}
</p>
)}
{rule.tags && rule.tags.length > 0 && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.tags)}
</span>
<div className="inline-flex gap-2">
{rule.tags.split(',').map((tag) => (
<span>
{
testResponse.tags?.find((t) => t.id === Number(tag))
?.label
}
</span>
))}
</div>
</p>
)}
</div>
</div>
<div className="border-t border-gray-500">
<div className="-mt-px flex">
<div className="flex w-0 flex-1 border-r border-gray-500">
<button
onClick={() =>
setOverrideRuleModal({ open: true, rule, testResponse })
}
className="focus:ring-blue relative -mr-px inline-flex w-0 flex-1 items-center justify-center rounded-bl-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
>
<PencilIcon className="mr-2 h-5 w-5" />
<span>{intl.formatMessage(globalMessages.edit)}</span>
</button>
</div>
<div className="-ml-px flex w-0 flex-1">
<button
onClick={async () => {
const res = await fetch(
`/api/v1/overrideRule/${rule.id}`,
{
method: 'DELETE',
}
);
if (!res.ok) throw new Error();
revalidate();
}}
className="focus:ring-blue relative inline-flex w-0 flex-1 items-center justify-center rounded-br-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
>
<TrashIcon className="mr-2 h-5 w-5" />
<span>{intl.formatMessage(globalMessages.delete)}</span>
</button>
</div>
</div>
</div>
</li>
))}
</>
);
};
export default OverrideRuleTile;

View File

@@ -1,14 +1,21 @@
import Button from '@app/components/Common/Button';
import Modal from '@app/components/Common/Modal'; import Modal from '@app/components/Common/Modal';
import SensitiveInput from '@app/components/Common/SensitiveInput'; import SensitiveInput from '@app/components/Common/SensitiveInput';
import OverrideRuleTile from '@app/components/Settings/OverrideRule/OverrideRuleTile';
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { PlusIcon } from '@heroicons/react/24/solid';
import type OverrideRule from '@server/entity/OverrideRule';
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
import type { RadarrSettings } from '@server/lib/settings'; import type { RadarrSettings } from '@server/lib/settings';
import { Field, Formik } from 'formik'; import { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import Select from 'react-select'; import Select from 'react-select';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup'; import * as Yup from 'yup';
type OptionType = { type OptionType = {
@@ -69,41 +76,46 @@ const messages = defineMessages('components.Settings.RadarrModal', {
announced: 'Announced', announced: 'Announced',
inCinemas: 'In Cinemas', inCinemas: 'In Cinemas',
released: 'Released', released: 'Released',
overrideRules: 'Override Rules',
addrule: 'New Override Rule',
}); });
interface TestResponse {
profiles: {
id: number;
name: string;
}[];
rootFolders: {
id: number;
path: string;
}[];
tags: {
id: number;
label: string;
}[];
urlBase?: string;
}
interface RadarrModalProps { interface RadarrModalProps {
radarr: RadarrSettings | null; radarr: RadarrSettings | null;
onClose: () => void; onClose: () => void;
onSave: () => void; onSave: () => void;
overrideRuleModal: { open: boolean; rule: OverrideRule | null };
setOverrideRuleModal: ({
open,
rule,
testResponse,
}: {
open: boolean;
rule: OverrideRule | null;
testResponse: DVRTestResponse;
}) => void;
} }
const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => { const RadarrModal = ({
onClose,
radarr,
onSave,
overrideRuleModal,
setOverrideRuleModal,
}: RadarrModalProps) => {
const intl = useIntl(); const intl = useIntl();
const { data: rules, mutate: revalidate } =
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
const initialLoad = useRef(false); const initialLoad = useRef(false);
const { addToast } = useToasts(); const { addToast } = useToasts();
const [isValidated, setIsValidated] = useState(radarr ? true : false); const [isValidated, setIsValidated] = useState(radarr ? true : false);
const [isTesting, setIsTesting] = useState(false); const [isTesting, setIsTesting] = useState(false);
const [testResponse, setTestResponse] = useState<TestResponse>({ const [testResponse, setTestResponse] = useState<DVRTestResponse>({
profiles: [], profiles: [],
rootFolders: [], rootFolders: [],
tags: [], tags: [],
}); });
const RadarrSettingsSchema = Yup.object().shape({ const RadarrSettingsSchema = Yup.object().shape({
name: Yup.string().required( name: Yup.string().required(
intl.formatMessage(messages.validationNameRequired) intl.formatMessage(messages.validationNameRequired)
@@ -130,7 +142,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
intl.formatMessage(messages.validationMinimumAvailabilityRequired) intl.formatMessage(messages.validationMinimumAvailabilityRequired)
), ),
externalUrl: Yup.string() externalUrl: Yup.string()
.url(intl.formatMessage(messages.validationApplicationUrl)) .matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationApplicationUrl)
)
.test( .test(
'no-trailing-slash', 'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash), intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
@@ -217,6 +232,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
} }
}, [radarr, testConnection]); }, [radarr, testConnection]);
useEffect(() => {
revalidate();
}, [overrideRuleModal, revalidate]);
return ( return (
<Transition <Transition
as="div" as="div"
@@ -360,6 +379,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
values.is4k ? messages.edit4kradarr : messages.editradarr values.is4k ? messages.edit4kradarr : messages.editradarr
) )
} }
backgroundClickable={!overrideRuleModal.open}
> >
<div className="mb-6"> <div className="mb-6">
<div className="form-row"> <div className="form-row">
@@ -750,6 +770,38 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
</div> </div>
</div> </div>
</div> </div>
<h3 className="mb-4 text-xl font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.overrideRules)}
</h3>
<ul className="grid grid-cols-2 gap-6">
{rules && (
<OverrideRuleTile
rules={rules}
setOverrideRuleModal={setOverrideRuleModal}
testResponse={testResponse}
radarr={radarr}
revalidate={revalidate}
/>
)}
<li className="min-h-[8rem] rounded-lg border-2 border-dashed border-gray-400 shadow sm:min-h-[11rem]">
<div className="flex h-full w-full items-center justify-center">
<Button
buttonType="ghost"
onClick={() =>
setOverrideRuleModal({
open: true,
rule: null,
testResponse,
})
}
disabled={!isValidated}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addrule)}</span>
</Button>
</div>
</li>
</ul>
</Modal> </Modal>
); );
}} }}

View File

@@ -139,7 +139,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
), ),
jellyfinExternalUrl: Yup.string() jellyfinExternalUrl: Yup.string()
.nullable() .nullable()
.url(intl.formatMessage(messages.validationUrl)) .matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationUrl)
)
.test( .test(
'no-trailing-slash', 'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash), intl.formatMessage(messages.validationUrlTrailingSlash),
@@ -147,7 +150,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
), ),
jellyfinForgotPasswordUrl: Yup.string() jellyfinForgotPasswordUrl: Yup.string()
.nullable() .nullable()
.url(intl.formatMessage(messages.validationUrl)) .matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationUrl)
)
.test( .test(
'no-trailing-slash', 'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash), intl.formatMessage(messages.validationUrlTrailingSlash),

View File

@@ -55,6 +55,17 @@ 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',
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 = () => { const SettingsMain = () => {
@@ -76,12 +87,21 @@ const SettingsMain = () => {
intl.formatMessage(messages.validationApplicationTitle) intl.formatMessage(messages.validationApplicationTitle)
), ),
applicationUrl: Yup.string() applicationUrl: Yup.string()
.url(intl.formatMessage(messages.validationApplicationUrl)) .matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationApplicationUrl)
)
.test( .test(
'no-trailing-slash', 'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash), intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
(value) => !value || !value.endsWith('/') (value) => !value || !value.endsWith('/')
), ),
proxyPort: Yup.number().when('proxyEnabled', {
is: (proxyEnabled: boolean) => proxyEnabled,
then: Yup.number().required(
intl.formatMessage(messages.validationProxyPort)
),
}),
}); });
const regenerate = async () => { const regenerate = async () => {
@@ -137,6 +157,14 @@ const SettingsMain = () => {
partialRequestsEnabled: data?.partialRequestsEnabled, partialRequestsEnabled: data?.partialRequestsEnabled,
trustProxy: data?.trustProxy, trustProxy: data?.trustProxy,
cacheImages: data?.cacheImages, 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 enableReinitialize
validationSchema={MainSettingsSchema} validationSchema={MainSettingsSchema}
@@ -158,6 +186,16 @@ const SettingsMain = () => {
partialRequestsEnabled: values.partialRequestsEnabled, partialRequestsEnabled: values.partialRequestsEnabled,
trustProxy: values.trustProxy, trustProxy: values.trustProxy,
cacheImages: values.cacheImages, 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(); if (!res.ok) throw new Error();
@@ -437,6 +475,176 @@ const SettingsMain = () => {
/> />
</div> </div>
</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="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

@@ -190,7 +190,10 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
otherwise: Yup.string().nullable(), otherwise: Yup.string().nullable(),
}), }),
tautulliExternalUrl: Yup.string() tautulliExternalUrl: Yup.string()
.url(intl.formatMessage(messages.validationUrl)) .matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationUrl)
)
.test( .test(
'no-trailing-slash', 'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash), intl.formatMessage(messages.validationUrlTrailingSlash),

View File

@@ -6,12 +6,14 @@ import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal'; import Modal from '@app/components/Common/Modal';
import PageTitle from '@app/components/Common/PageTitle'; import PageTitle from '@app/components/Common/PageTitle';
import OverrideRuleModal from '@app/components/Settings/OverrideRule/OverrideRuleModal';
import RadarrModal from '@app/components/Settings/RadarrModal'; import RadarrModal from '@app/components/Settings/RadarrModal';
import SonarrModal from '@app/components/Settings/SonarrModal'; import SonarrModal from '@app/components/Settings/SonarrModal';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid'; import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
import type OverrideRule from '@server/entity/OverrideRule';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -57,6 +59,22 @@ interface ServerInstanceProps {
onDelete: () => void; onDelete: () => void;
} }
export interface DVRTestResponse {
profiles: {
id: number;
name: string;
}[];
rootFolders: {
id: number;
path: string;
}[];
tags: {
id: number;
label: string;
}[];
urlBase?: string;
}
const ServerInstance = ({ const ServerInstance = ({
name, name,
hostname, hostname,
@@ -193,6 +211,15 @@ const SettingsServices = () => {
type: 'radarr', type: 'radarr',
serverId: null, serverId: null,
}); });
const [overrideRuleModal, setOverrideRuleModal] = useState<{
open: boolean;
rule: OverrideRule | null;
testResponse: DVRTestResponse | null;
}>({
open: false,
rule: null,
testResponse: null,
});
const deleteServer = async () => { const deleteServer = async () => {
const res = await fetch( const res = await fetch(
@@ -227,26 +254,51 @@ const SettingsServices = () => {
})} })}
</p> </p>
</div> </div>
{overrideRuleModal.open && overrideRuleModal.testResponse && (
<OverrideRuleModal
rule={overrideRuleModal.rule}
onClose={() =>
setOverrideRuleModal({
open: false,
rule: null,
testResponse: null,
})
}
testResponse={overrideRuleModal.testResponse}
radarrId={editRadarrModal.radarr?.id}
sonarrId={editSonarrModal.sonarr?.id}
/>
)}
{editRadarrModal.open && ( {editRadarrModal.open && (
<RadarrModal <RadarrModal
radarr={editRadarrModal.radarr} radarr={editRadarrModal.radarr}
onClose={() => setEditRadarrModal({ open: false, radarr: null })} onClose={() => {
if (!overrideRuleModal.open)
setEditRadarrModal({ open: false, radarr: null });
}}
onSave={() => { onSave={() => {
revalidateRadarr(); revalidateRadarr();
mutate('/api/v1/settings/public'); mutate('/api/v1/settings/public');
setEditRadarrModal({ open: false, radarr: null }); setEditRadarrModal({ open: false, radarr: null });
}} }}
overrideRuleModal={overrideRuleModal}
setOverrideRuleModal={setOverrideRuleModal}
/> />
)} )}
{editSonarrModal.open && ( {editSonarrModal.open && (
<SonarrModal <SonarrModal
sonarr={editSonarrModal.sonarr} sonarr={editSonarrModal.sonarr}
onClose={() => setEditSonarrModal({ open: false, sonarr: null })} onClose={() => {
if (!overrideRuleModal.open)
setEditSonarrModal({ open: false, sonarr: null });
}}
onSave={() => { onSave={() => {
revalidateSonarr(); revalidateSonarr();
mutate('/api/v1/settings/public'); mutate('/api/v1/settings/public');
setEditSonarrModal({ open: false, sonarr: null }); setEditSonarrModal({ open: false, sonarr: null });
}} }}
overrideRuleModal={overrideRuleModal}
setOverrideRuleModal={setOverrideRuleModal}
/> />
)} )}
<Transition <Transition

View File

@@ -1,8 +1,14 @@
import Button from '@app/components/Common/Button';
import Modal from '@app/components/Common/Modal'; import Modal from '@app/components/Common/Modal';
import SensitiveInput from '@app/components/Common/SensitiveInput'; import SensitiveInput from '@app/components/Common/SensitiveInput';
import OverrideRuleTile from '@app/components/Settings/OverrideRule/OverrideRuleTile';
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { PlusIcon } from '@heroicons/react/24/solid';
import type OverrideRule from '@server/entity/OverrideRule';
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
import type { SonarrSettings } from '@server/lib/settings'; import type { SonarrSettings } from '@server/lib/settings';
import { Field, Formik } from 'formik'; import { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
@@ -10,6 +16,7 @@ import { useIntl } from 'react-intl';
import type { OnChangeValue } from 'react-select'; import type { OnChangeValue } from 'react-select';
import Select from 'react-select'; import Select from 'react-select';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup'; import * as Yup from 'yup';
type OptionType = { type OptionType = {
@@ -75,46 +82,56 @@ const messages = defineMessages('components.Settings.SonarrModal', {
animeTags: 'Anime Tags', animeTags: 'Anime Tags',
notagoptions: 'No tags.', notagoptions: 'No tags.',
selecttags: 'Select tags', selecttags: 'Select tags',
overrideRules: 'Override Rules',
addrule: 'New Override Rule',
}); });
interface TestResponse { interface SonarrTestResponse extends DVRTestResponse {
profiles: { languageProfiles:
id: number; | {
name: string; id: number;
}[]; name: string;
rootFolders: { }[]
id: number; | null;
path: string;
}[];
languageProfiles: {
id: number;
name: string;
}[];
tags: {
id: number;
label: string;
}[];
urlBase?: string;
} }
interface SonarrModalProps { interface SonarrModalProps {
sonarr: SonarrSettings | null; sonarr: SonarrSettings | null;
onClose: () => void; onClose: () => void;
onSave: () => void; onSave: () => void;
overrideRuleModal: { open: boolean; rule: OverrideRule | null };
setOverrideRuleModal: ({
open,
rule,
testResponse,
}: {
open: boolean;
rule: OverrideRule | null;
testResponse: DVRTestResponse;
}) => void;
} }
const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { const SonarrModal = ({
onClose,
sonarr,
onSave,
overrideRuleModal,
setOverrideRuleModal,
}: SonarrModalProps) => {
const intl = useIntl(); const intl = useIntl();
const { data: rules, mutate: revalidate } =
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
const initialLoad = useRef(false); const initialLoad = useRef(false);
const { addToast } = useToasts(); const { addToast } = useToasts();
const [isValidated, setIsValidated] = useState(sonarr ? true : false); const [isValidated, setIsValidated] = useState(sonarr ? true : false);
const [isTesting, setIsTesting] = useState(false); const [isTesting, setIsTesting] = useState(false);
const [testResponse, setTestResponse] = useState<TestResponse>({ const [testResponse, setTestResponse] = useState<SonarrTestResponse>({
profiles: [], profiles: [],
rootFolders: [], rootFolders: [],
languageProfiles: [], languageProfiles: null,
tags: [], tags: [],
}); });
const SonarrSettingsSchema = Yup.object().shape({ const SonarrSettingsSchema = Yup.object().shape({
name: Yup.string().required( name: Yup.string().required(
intl.formatMessage(messages.validationNameRequired) intl.formatMessage(messages.validationNameRequired)
@@ -137,11 +154,16 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
activeProfileId: Yup.string().required( activeProfileId: Yup.string().required(
intl.formatMessage(messages.validationProfileRequired) intl.formatMessage(messages.validationProfileRequired)
), ),
activeLanguageProfileId: Yup.number().required( activeLanguageProfileId: testResponse.languageProfiles
intl.formatMessage(messages.validationLanguageProfileRequired) ? Yup.number().required(
), intl.formatMessage(messages.validationLanguageProfileRequired)
)
: Yup.number(),
externalUrl: Yup.string() externalUrl: Yup.string()
.url(intl.formatMessage(messages.validationApplicationUrl)) .matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationApplicationUrl)
)
.test( .test(
'no-trailing-slash', 'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash), intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
@@ -190,7 +212,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
}), }),
}); });
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
const data: TestResponse = await res.json(); const data: SonarrTestResponse = await res.json();
setIsValidated(true); setIsValidated(true);
setTestResponse(data); setTestResponse(data);
@@ -228,6 +250,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
} }
}, [sonarr, testConnection]); }, [sonarr, testConnection]);
useEffect(() => {
revalidate();
}, [overrideRuleModal, revalidate]);
return ( return (
<Transition <Transition
as="div" as="div"
@@ -395,6 +421,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
values.is4k ? messages.edit4ksonarr : messages.editsonarr values.is4k ? messages.edit4ksonarr : messages.editsonarr
) )
} }
backgroundClickable={!overrideRuleModal.open}
> >
<div className="mb-6"> <div className="mb-6">
<div className="form-row"> <div className="form-row">
@@ -658,54 +685,56 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
)} )}
</div> </div>
</div> </div>
<div className="form-row"> {testResponse.languageProfiles && (
<label <div className="form-row">
htmlFor="activeLanguageProfileId" <label
className="text-label" htmlFor="activeLanguageProfileId"
> className="text-label"
{intl.formatMessage(messages.languageprofile)} >
<span className="label-required">*</span> {intl.formatMessage(messages.languageprofile)}
</label> <span className="label-required">*</span>
<div className="form-input-area"> </label>
<div className="form-input-field"> <div className="form-input-area">
<Field <div className="form-input-field">
as="select" <Field
id="activeLanguageProfileId" as="select"
name="activeLanguageProfileId" id="activeLanguageProfileId"
disabled={!isValidated || isTesting} name="activeLanguageProfileId"
> disabled={!isValidated || isTesting}
<option value=""> >
{isTesting <option value="">
? intl.formatMessage( {isTesting
messages.loadinglanguageprofiles ? intl.formatMessage(
) messages.loadinglanguageprofiles
: !isValidated )
? intl.formatMessage( : !isValidated
messages.testFirstLanguageProfiles ? intl.formatMessage(
) messages.testFirstLanguageProfiles
: intl.formatMessage( )
messages.selectLanguageProfile : intl.formatMessage(
)} messages.selectLanguageProfile
</option> )}
{testResponse.languageProfiles.length > 0 && </option>
testResponse.languageProfiles.map((language) => ( {testResponse.languageProfiles.length > 0 &&
<option testResponse.languageProfiles.map((language) => (
key={`loaded-profile-${language.id}`} <option
value={language.id} key={`loaded-profile-${language.id}`}
> value={language.id}
{language.name} >
</option> {language.name}
))} </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)}
@@ -863,53 +892,55 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
)} )}
</div> </div>
</div> </div>
<div className="form-row"> {testResponse.languageProfiles && (
<label <div className="form-row">
htmlFor="activeAnimeLanguageProfileId" <label
className="text-label" htmlFor="activeAnimeLanguageProfileId"
> className="text-label"
{intl.formatMessage(messages.animelanguageprofile)} >
</label> {intl.formatMessage(messages.animelanguageprofile)}
<div className="form-input-area"> </label>
<div className="form-input-field"> <div className="form-input-area">
<Field <div className="form-input-field">
as="select" <Field
id="activeAnimeLanguageProfileId" as="select"
name="activeAnimeLanguageProfileId" id="activeAnimeLanguageProfileId"
disabled={!isValidated || isTesting} name="activeAnimeLanguageProfileId"
> disabled={!isValidated || isTesting}
<option value=""> >
{isTesting <option value="">
? intl.formatMessage( {isTesting
messages.loadinglanguageprofiles ? intl.formatMessage(
) messages.loadinglanguageprofiles
: !isValidated )
? intl.formatMessage( : !isValidated
messages.testFirstLanguageProfiles ? intl.formatMessage(
) messages.testFirstLanguageProfiles
: intl.formatMessage( )
messages.selectLanguageProfile : intl.formatMessage(
)} messages.selectLanguageProfile
</option> )}
{testResponse.languageProfiles.length > 0 && </option>
testResponse.languageProfiles.map((language) => ( {testResponse.languageProfiles.length > 0 &&
<option testResponse.languageProfiles.map((language) => (
key={`loaded-profile-${language.id}`} <option
value={language.id} key={`loaded-profile-${language.id}`}
> value={language.id}
{language.name} >
</option> {language.name}
))} </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)}
@@ -1045,6 +1076,38 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
</div> </div>
</div> </div>
</div> </div>
<h3 className="mb-4 text-xl font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.overrideRules)}
</h3>
<ul className="grid grid-cols-2 gap-6">
{rules && (
<OverrideRuleTile
rules={rules}
setOverrideRuleModal={setOverrideRuleModal}
testResponse={testResponse}
sonarr={sonarr}
revalidate={revalidate}
/>
)}
<li className="min-h-[8rem] rounded-lg border-2 border-dashed border-gray-400 shadow sm:min-h-[11rem]">
<div className="flex h-full w-full items-center justify-center">
<Button
buttonType="ghost"
onClick={() =>
setOverrideRuleModal({
open: true,
rule: null,
testResponse,
})
}
disabled={!isValidated}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addrule)}</span>
</Button>
</div>
</li>
</ul>
</Modal> </Modal>
); );
}} }}

View File

@@ -44,6 +44,8 @@ const messages = defineMessages(
toastSettingsSuccess: 'Settings saved successfully!', toastSettingsSuccess: 'Settings saved successfully!',
toastSettingsFailure: 'Something went wrong while saving settings.', toastSettingsFailure: 'Something went wrong while saving settings.',
toastSettingsFailureEmail: 'This email is already taken!', toastSettingsFailureEmail: 'This email is already taken!',
toastSettingsFailureEmailEmpty:
'Another user already has this username. You must set an email',
region: 'Discover Region', region: 'Discover Region',
regionTip: 'Filter content by regional availability', regionTip: 'Filter content by regional availability',
originallanguage: 'Discover Language', originallanguage: 'Discover Language',
@@ -138,7 +140,7 @@ const UserGeneralSettings = () => {
</div> </div>
<Formik <Formik
initialValues={{ initialValues={{
displayName: data?.username ?? '', displayName: data?.username !== user?.email ? data?.username : '',
email: data?.email?.includes('@') ? data.email : '', email: data?.email?.includes('@') ? data.email : '',
discordId: data?.discordId ?? '', discordId: data?.discordId ?? '',
locale: data?.locale, locale: data?.locale,
@@ -203,10 +205,23 @@ const UserGeneralSettings = () => {
/* empty */ /* empty */
} }
if (errorData?.message === ApiErrorCode.InvalidEmail) { if (errorData?.message === ApiErrorCode.InvalidEmail) {
addToast(intl.formatMessage(messages.toastSettingsFailureEmail), { if (values.email) {
autoDismiss: true, addToast(
appearance: 'error', intl.formatMessage(messages.toastSettingsFailureEmail),
}); {
autoDismiss: true,
appearance: 'error',
}
);
} else {
addToast(
intl.formatMessage(messages.toastSettingsFailureEmailEmpty),
{
autoDismiss: true,
appearance: 'error',
}
);
}
} else { } else {
addToast(intl.formatMessage(messages.toastSettingsFailure), { addToast(intl.formatMessage(messages.toastSettingsFailure), {
autoDismiss: true, autoDismiss: true,
@@ -284,9 +299,9 @@ const UserGeneralSettings = () => {
name="displayName" name="displayName"
type="text" type="text"
placeholder={ placeholder={
user?.username ||
user?.jellyfinUsername || user?.jellyfinUsername ||
user?.plexUsername user?.plexUsername ||
user?.email
} }
/> />
</div> </div>

View File

@@ -25,15 +25,14 @@ async function extractMessages(
try { try {
const formattedMessages = messages const formattedMessages = messages
.trim() .trim()
.replace(/^\s*(['"])?([a-zA-Z0-9_-]+)(['"])?:/gm, '"$2":') .replace(/^\s*(['"])?([a-zA-Z0-9_-]+)(['"])?:[\s\n]*/gm, '"$2":')
.replace( .replace(/^"[a-zA-Z0-9_-]+":'.*',?$/gm, (match) => {
/'.*'/g, const parts = /^("[a-zA-Z0-9_-]+":)'(.*)',?$/.exec(match);
(match) => if (!parts) return match;
`"${match return `${parts[1]}"${parts[2]
.match(/'(.*)'/)?.[1] .replace(/\\/g, '\\\\')
.replace(/\\/g, '\\\\') .replace(/"/g, '\\"')}",`;
.replace(/"/g, '\\"')}"` })
)
.replace(/,$/, ''); .replace(/,$/, '');
const messagesJson = JSON.parse(`{${formattedMessages}}`); const messagesJson = JSON.parse(`{${formattedMessages}}`);
return { namespace: namespace.trim(), messages: messagesJson }; return { namespace: namespace.trim(), messages: messagesJson };

View File

@@ -100,6 +100,7 @@
"components.Discover.StudioSlider.studios": "Studios", "components.Discover.StudioSlider.studios": "Studios",
"components.Discover.TvGenreList.seriesgenres": "Series Genres", "components.Discover.TvGenreList.seriesgenres": "Series Genres",
"components.Discover.TvGenreSlider.tvgenres": "Series Genres", "components.Discover.TvGenreSlider.tvgenres": "Series Genres",
"components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series",
"components.Discover.createnewslider": "Create New Slider", "components.Discover.createnewslider": "Create New Slider",
"components.Discover.customizediscover": "Customize Discover", "components.Discover.customizediscover": "Customize Discover",
"components.Discover.discover": "Discover", "components.Discover.discover": "Discover",
@@ -298,7 +299,6 @@
"components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {play} other {plays}}", "components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {play} other {plays}}",
"components.ManageSlideOver.removearr": "Remove from {arr}", "components.ManageSlideOver.removearr": "Remove from {arr}",
"components.ManageSlideOver.removearr4k": "Remove from 4K {arr}", "components.ManageSlideOver.removearr4k": "Remove from 4K {arr}",
"components.RequestList.RequestItem.removearr": "Remove from {arr}",
"components.ManageSlideOver.tvshow": "series", "components.ManageSlideOver.tvshow": "series",
"components.MediaSlider.ShowMoreCard.seemore": "See More", "components.MediaSlider.ShowMoreCard.seemore": "See More",
"components.MovieDetails.MovieCast.fullcast": "Full Cast", "components.MovieDetails.MovieCast.fullcast": "Full Cast",
@@ -494,6 +494,7 @@
"components.RequestList.RequestItem.modified": "Modified", "components.RequestList.RequestItem.modified": "Modified",
"components.RequestList.RequestItem.modifieduserdate": "{date} by {user}", "components.RequestList.RequestItem.modifieduserdate": "{date} by {user}",
"components.RequestList.RequestItem.profileName": "Profile", "components.RequestList.RequestItem.profileName": "Profile",
"components.RequestList.RequestItem.removearr": "Remove from {arr}",
"components.RequestList.RequestItem.requested": "Requested", "components.RequestList.RequestItem.requested": "Requested",
"components.RequestList.RequestItem.requesteddate": "Requested", "components.RequestList.RequestItem.requesteddate": "Requested",
"components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}", "components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
@@ -589,6 +590,7 @@
"components.Selector.searchKeywords": "Search keywords…", "components.Selector.searchKeywords": "Search keywords…",
"components.Selector.searchStatus": "Select status...", "components.Selector.searchStatus": "Select status...",
"components.Selector.searchStudios": "Search studios…", "components.Selector.searchStudios": "Search studios…",
"components.Selector.searchUsers": "Select users…",
"components.Selector.showless": "Show Less", "components.Selector.showless": "Show Less",
"components.Selector.showmore": "Show More", "components.Selector.showmore": "Show More",
"components.Selector.starttyping": "Starting typing to search.", "components.Selector.starttyping": "Starting typing to search.",
@@ -727,37 +729,63 @@
"components.Settings.Notifications.validationSmtpPortRequired": "You must provide a valid port number", "components.Settings.Notifications.validationSmtpPortRequired": "You must provide a valid port number",
"components.Settings.Notifications.validationTypes": "You must select at least one notification type", "components.Settings.Notifications.validationTypes": "You must select at least one notification type",
"components.Settings.Notifications.validationUrl": "You must provide a valid URL", "components.Settings.Notifications.validationUrl": "You must provide a valid URL",
"components.Settings.Notifications.validationWebhookRoleId": "You must provide a valid Discord Role ID",
"components.Settings.Notifications.webhookRoleId": "Notification Role ID",
"components.Settings.Notifications.webhookRoleIdTip": "The role ID to mention in the webhook message. Leave empty to disable mentions",
"components.Settings.Notifications.webhookUrl": "Webhook URL", "components.Settings.Notifications.webhookUrl": "Webhook URL",
"components.Settings.Notifications.webhookUrlTip": "Create a <DiscordWebhookLink>webhook integration</DiscordWebhookLink> in your server", "components.Settings.Notifications.webhookUrlTip": "Create a <DiscordWebhookLink>webhook integration</DiscordWebhookLink> in your server",
"components.Settings.OverrideRuleTile.conditions": "Conditions",
"components.Settings.OverrideRuleTile.genre": "Genre",
"components.Settings.OverrideRuleTile.keywords": "Keywords",
"components.Settings.OverrideRuleTile.language": "Language",
"components.Settings.OverrideRuleTile.qualityprofile": "Quality Profile",
"components.Settings.OverrideRuleTile.rootfolder": "Root Folder",
"components.Settings.OverrideRuleTile.settings": "Settings",
"components.Settings.OverrideRuleTile.tags": "Tags",
"components.Settings.OverrideRuleTile.users": "Users",
"components.Settings.RadarrModal.add": "Add Server", "components.Settings.RadarrModal.add": "Add Server",
"components.Settings.RadarrModal.addrule": "New Override Rule",
"components.Settings.RadarrModal.announced": "Announced", "components.Settings.RadarrModal.announced": "Announced",
"components.Settings.RadarrModal.apiKey": "API Key", "components.Settings.RadarrModal.apiKey": "API Key",
"components.Settings.RadarrModal.baseUrl": "URL Base", "components.Settings.RadarrModal.baseUrl": "URL Base",
"components.Settings.RadarrModal.conditions": "Conditions",
"components.Settings.RadarrModal.conditionsDescription": "Specifies conditions before applying parameter changes. Each field must be validated for the rules to be applied (AND operation). A field is considered verified if any of its properties match (OR operation).",
"components.Settings.RadarrModal.create": "Create rule",
"components.Settings.RadarrModal.create4kradarr": "Add New 4K Radarr Server", "components.Settings.RadarrModal.create4kradarr": "Add New 4K Radarr Server",
"components.Settings.RadarrModal.createradarr": "Add New Radarr Server", "components.Settings.RadarrModal.createradarr": "Add New Radarr Server",
"components.Settings.RadarrModal.createrule": "New Override Rule",
"components.Settings.RadarrModal.default4kserver": "Default 4K Server", "components.Settings.RadarrModal.default4kserver": "Default 4K Server",
"components.Settings.RadarrModal.defaultserver": "Default Server", "components.Settings.RadarrModal.defaultserver": "Default Server",
"components.Settings.RadarrModal.edit4kradarr": "Edit 4K Radarr Server", "components.Settings.RadarrModal.edit4kradarr": "Edit 4K Radarr Server",
"components.Settings.RadarrModal.editradarr": "Edit Radarr Server", "components.Settings.RadarrModal.editradarr": "Edit Radarr Server",
"components.Settings.RadarrModal.editrule": "Edit Override Rule",
"components.Settings.RadarrModal.enableSearch": "Enable Automatic Search", "components.Settings.RadarrModal.enableSearch": "Enable Automatic Search",
"components.Settings.RadarrModal.externalUrl": "External URL", "components.Settings.RadarrModal.externalUrl": "External URL",
"components.Settings.RadarrModal.genres": "Genres",
"components.Settings.RadarrModal.hostname": "Hostname or IP Address", "components.Settings.RadarrModal.hostname": "Hostname or IP Address",
"components.Settings.RadarrModal.inCinemas": "In Cinemas", "components.Settings.RadarrModal.inCinemas": "In Cinemas",
"components.Settings.RadarrModal.keywords": "Keywords",
"components.Settings.RadarrModal.languages": "Languages",
"components.Settings.RadarrModal.loadingTags": "Loading tags…", "components.Settings.RadarrModal.loadingTags": "Loading tags…",
"components.Settings.RadarrModal.loadingprofiles": "Loading quality profiles…", "components.Settings.RadarrModal.loadingprofiles": "Loading quality profiles…",
"components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…", "components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…",
"components.Settings.RadarrModal.minimumAvailability": "Minimum Availability", "components.Settings.RadarrModal.minimumAvailability": "Minimum Availability",
"components.Settings.RadarrModal.notagoptions": "No tags.", "components.Settings.RadarrModal.notagoptions": "No tags.",
"components.Settings.RadarrModal.overrideRules": "Override Rules",
"components.Settings.RadarrModal.port": "Port", "components.Settings.RadarrModal.port": "Port",
"components.Settings.RadarrModal.qualityprofile": "Quality Profile", "components.Settings.RadarrModal.qualityprofile": "Quality Profile",
"components.Settings.RadarrModal.released": "Released", "components.Settings.RadarrModal.released": "Released",
"components.Settings.RadarrModal.rootfolder": "Root Folder", "components.Settings.RadarrModal.rootfolder": "Root Folder",
"components.Settings.RadarrModal.ruleCreated": "Override rule created successfully!",
"components.Settings.RadarrModal.ruleUpdated": "Override rule updated successfully!",
"components.Settings.RadarrModal.selectMinimumAvailability": "Select minimum availability", "components.Settings.RadarrModal.selectMinimumAvailability": "Select minimum availability",
"components.Settings.RadarrModal.selectQualityProfile": "Select quality profile", "components.Settings.RadarrModal.selectQualityProfile": "Select quality profile",
"components.Settings.RadarrModal.selectRootFolder": "Select root folder", "components.Settings.RadarrModal.selectRootFolder": "Select root folder",
"components.Settings.RadarrModal.selecttags": "Select tags", "components.Settings.RadarrModal.selecttags": "Select tags",
"components.Settings.RadarrModal.server4k": "4K Server", "components.Settings.RadarrModal.server4k": "4K Server",
"components.Settings.RadarrModal.servername": "Server Name", "components.Settings.RadarrModal.servername": "Server Name",
"components.Settings.RadarrModal.settings": "Settings",
"components.Settings.RadarrModal.settingsDescription": "Specifies which settings will be changed when the above conditions are met.",
"components.Settings.RadarrModal.ssl": "Use SSL", "components.Settings.RadarrModal.ssl": "Use SSL",
"components.Settings.RadarrModal.syncEnabled": "Enable Scan", "components.Settings.RadarrModal.syncEnabled": "Enable Scan",
"components.Settings.RadarrModal.tagRequests": "Tag Requests", "components.Settings.RadarrModal.tagRequests": "Tag Requests",
@@ -768,6 +796,7 @@
"components.Settings.RadarrModal.testFirstTags": "Test connection to load tags", "components.Settings.RadarrModal.testFirstTags": "Test connection to load tags",
"components.Settings.RadarrModal.toastRadarrTestFailure": "Failed to connect to Radarr.", "components.Settings.RadarrModal.toastRadarrTestFailure": "Failed to connect to Radarr.",
"components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarr connection established successfully!", "components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarr connection established successfully!",
"components.Settings.RadarrModal.users": "Users",
"components.Settings.RadarrModal.validationApiKeyRequired": "You must provide an API key", "components.Settings.RadarrModal.validationApiKeyRequired": "You must provide an API key",
"components.Settings.RadarrModal.validationApplicationUrl": "You must provide a valid URL", "components.Settings.RadarrModal.validationApplicationUrl": "You must provide a valid URL",
"components.Settings.RadarrModal.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash", "components.Settings.RadarrModal.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
@@ -885,6 +914,15 @@
"components.Settings.SettingsMain.originallanguage": "Discover Language", "components.Settings.SettingsMain.originallanguage": "Discover Language",
"components.Settings.SettingsMain.originallanguageTip": "Filter content by original language", "components.Settings.SettingsMain.originallanguageTip": "Filter content by original language",
"components.Settings.SettingsMain.partialRequestsEnabled": "Allow Partial Series Requests", "components.Settings.SettingsMain.partialRequestsEnabled": "Allow Partial Series Requests",
"components.Settings.SettingsMain.proxyBypassFilter": "Proxy Ignored Addresses",
"components.Settings.SettingsMain.proxyBypassFilterTip": "Use ',' as a separator, and '*.' as a wildcard for subdomains",
"components.Settings.SettingsMain.proxyBypassLocalAddresses": "Bypass Proxy for Local Addresses",
"components.Settings.SettingsMain.proxyEnabled": "HTTP(S) Proxy",
"components.Settings.SettingsMain.proxyHostname": "Proxy Hostname",
"components.Settings.SettingsMain.proxyPassword": "Proxy Password",
"components.Settings.SettingsMain.proxyPort": "Proxy Port",
"components.Settings.SettingsMain.proxySsl": "Use SSL For Proxy",
"components.Settings.SettingsMain.proxyUser": "Proxy Username",
"components.Settings.SettingsMain.region": "Discover Region", "components.Settings.SettingsMain.region": "Discover Region",
"components.Settings.SettingsMain.regionTip": "Filter content by regional availability", "components.Settings.SettingsMain.regionTip": "Filter content by regional availability",
"components.Settings.SettingsMain.toastApiKeyFailure": "Something went wrong while generating a new API key.", "components.Settings.SettingsMain.toastApiKeyFailure": "Something went wrong while generating a new API key.",
@@ -896,6 +934,7 @@
"components.Settings.SettingsMain.validationApplicationTitle": "You must provide an application title", "components.Settings.SettingsMain.validationApplicationTitle": "You must provide an application title",
"components.Settings.SettingsMain.validationApplicationUrl": "You must provide a valid URL", "components.Settings.SettingsMain.validationApplicationUrl": "You must provide a valid URL",
"components.Settings.SettingsMain.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash", "components.Settings.SettingsMain.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
"components.Settings.SettingsMain.validationProxyPort": "You must provide a valid port",
"components.Settings.SettingsUsers.defaultPermissions": "Default Permissions", "components.Settings.SettingsUsers.defaultPermissions": "Default Permissions",
"components.Settings.SettingsUsers.defaultPermissionsTip": "Initial permissions assigned to new users", "components.Settings.SettingsUsers.defaultPermissionsTip": "Initial permissions assigned to new users",
"components.Settings.SettingsUsers.localLogin": "Enable Local Sign-In", "components.Settings.SettingsUsers.localLogin": "Enable Local Sign-In",
@@ -910,6 +949,7 @@
"components.Settings.SettingsUsers.userSettingsDescription": "Configure global and default user settings.", "components.Settings.SettingsUsers.userSettingsDescription": "Configure global and default user settings.",
"components.Settings.SettingsUsers.users": "Users", "components.Settings.SettingsUsers.users": "Users",
"components.Settings.SonarrModal.add": "Add Server", "components.Settings.SonarrModal.add": "Add Server",
"components.Settings.SonarrModal.addrule": "New Override Rule",
"components.Settings.SonarrModal.animeSeriesType": "Anime Series Type", "components.Settings.SonarrModal.animeSeriesType": "Anime Series Type",
"components.Settings.SonarrModal.animeTags": "Anime Tags", "components.Settings.SonarrModal.animeTags": "Anime Tags",
"components.Settings.SonarrModal.animelanguageprofile": "Anime Language Profile", "components.Settings.SonarrModal.animelanguageprofile": "Anime Language Profile",
@@ -932,6 +972,7 @@
"components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…", "components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…",
"components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…", "components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…",
"components.Settings.SonarrModal.notagoptions": "No tags.", "components.Settings.SonarrModal.notagoptions": "No tags.",
"components.Settings.SonarrModal.overrideRules": "Override Rules",
"components.Settings.SonarrModal.port": "Port", "components.Settings.SonarrModal.port": "Port",
"components.Settings.SonarrModal.qualityprofile": "Quality Profile", "components.Settings.SonarrModal.qualityprofile": "Quality Profile",
"components.Settings.SonarrModal.rootfolder": "Root Folder", "components.Settings.SonarrModal.rootfolder": "Root Folder",
@@ -1084,7 +1125,7 @@
"components.Setup.finishing": "Finishing…", "components.Setup.finishing": "Finishing…",
"components.Setup.servertype": "Choose Server Type", "components.Setup.servertype": "Choose Server Type",
"components.Setup.setup": "Setup", "components.Setup.setup": "Setup",
"components.Setup.signin": "Sign in to your account", "components.Setup.signin": "Sign In",
"components.Setup.signinMessage": "Get started by signing in", "components.Setup.signinMessage": "Get started by signing in",
"components.Setup.signinWithEmby": "Enter your Emby details", "components.Setup.signinWithEmby": "Enter your Emby details",
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details", "components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
@@ -1235,6 +1276,7 @@
"components.UserProfile.UserSettings.UserGeneralSettings.seriesrequestlimit": "Series Request Limit", "components.UserProfile.UserSettings.UserGeneralSettings.seriesrequestlimit": "Series Request Limit",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailure": "Something went wrong while saving settings.", "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailure": "Something went wrong while saving settings.",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailureEmail": "This email is already taken!", "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailureEmail": "This email is already taken!",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailureEmailEmpty": "Another user already has this username. You must set an email",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!", "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!",
"components.UserProfile.UserSettings.UserGeneralSettings.user": "User", "components.UserProfile.UserSettings.UserGeneralSettings.user": "User",
"components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID", "components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID",

View File

@@ -1,18 +1,26 @@
import { defineMessages as intlDefineMessages } from 'react-intl'; import { defineMessages as intlDefineMessages } from 'react-intl';
export default function defineMessages( type Messages<T extends Record<string, string>> = {
[K in keyof T]: {
id: string;
defaultMessage: T[K];
};
};
export default function defineMessages<T extends Record<string, string>>(
prefix: string, prefix: string,
messages: Record<string, string> messages: T
) { ): Messages<T> {
const modifiedMessages: Record< const keys: (keyof T)[] = Object.keys(messages);
string, const modifiedMessagesEntries = keys.map((key) => [
{ id: string; defaultMessage: string } key,
> = {}; {
for (const key of Object.keys(messages)) { id: `${prefix}.${key as string}`,
modifiedMessages[key] = {
id: prefix + '.' + key,
defaultMessage: messages[key], defaultMessage: messages[key],
}; },
} ]);
const modifiedMessages: Messages<T> = Object.fromEntries(
modifiedMessagesEntries
);
return intlDefineMessages(modifiedMessages); return intlDefineMessages(modifiedMessages);
} }