mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-28 12:39:15 -05:00
Compare commits
40 Commits
v2.0.0
...
preview-ov
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c8fb4f4bb | ||
|
|
98ecdf16d2 | ||
|
|
a8bd51f8ab | ||
|
|
6ee96604ec | ||
|
|
aa3670322d | ||
|
|
38eaa30cee | ||
|
|
c0fce76db7 | ||
|
|
92341c8d06 | ||
|
|
cbcb71436d | ||
|
|
39a5ccb7f3 | ||
|
|
9b151feb4f | ||
|
|
fe5d016929 | ||
|
|
14f316a9a6 | ||
|
|
5c24e79b1d | ||
|
|
ba84212e68 | ||
|
|
f25b32aec8 | ||
|
|
5a13226877 | ||
|
|
694913c767 | ||
|
|
a2d2fd3c2a | ||
|
|
cb94ad5a2e | ||
|
|
2829c2548a | ||
|
|
64f4610b9f | ||
|
|
2d3b777daf | ||
|
|
cf59102ef9 | ||
|
|
ca838a00fa | ||
|
|
f2ed101e52 | ||
|
|
4b4eeb6ec7 | ||
|
|
d331798b28 | ||
|
|
f2b63156d1 | ||
|
|
326001c3ec | ||
|
|
0bbcfcbd5e | ||
|
|
32e0b129fe | ||
|
|
a2b3408c9a | ||
|
|
cbb1a74526 | ||
|
|
26c37ec067 | ||
|
|
4e48fdf2cb | ||
|
|
a351264b87 | ||
|
|
9de304d17a | ||
|
|
4945b54298 | ||
|
|
a0f80fe764 |
@@ -439,6 +439,15 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "M0NsTeRRR",
|
||||
"name": "Ludovic Ortega",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/37785089?v=4",
|
||||
"profile": "https://github.com/M0NsTeRRR",
|
||||
"contributions": [
|
||||
"security"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ config/logs/*
|
||||
config/*.json
|
||||
dist
|
||||
Dockerfile*
|
||||
docker-compose.yml
|
||||
compose.yaml
|
||||
docs
|
||||
LICENSE
|
||||
node_modules
|
||||
|
||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -40,7 +40,7 @@ docs export-ignore
|
||||
.all-contributorsrc export-ignore
|
||||
.editorconfig export-ignore
|
||||
Dockerfile.local export-ignore
|
||||
docker-compose.yml export-ignore
|
||||
compose.yaml export-ignore
|
||||
stylelint.config.js export-ignore
|
||||
|
||||
public/os_logo_filled.png export-ignore
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,6 +34,7 @@ yarn-error.log*
|
||||
# database
|
||||
config/db/*.sqlite3*
|
||||
config/settings.json
|
||||
config/settings.old.json
|
||||
|
||||
# logs
|
||||
config/logs/*.log*
|
||||
|
||||
@@ -48,11 +48,11 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
||||
4. Run the development environment:
|
||||
|
||||
```bash
|
||||
pnpm
|
||||
pnpm install
|
||||
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.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<a href="http://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-47-orange.svg"/></a>
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-48-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
**Jellyseerr** is a free and open source software application for managing requests for your media library.
|
||||
@@ -146,6 +146,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/XDark187"><img src="https://avatars.githubusercontent.com/u/39034192?v=4?s=100" width="100px;" alt="Baraa"/><br /><sub><b>Baraa</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=XDark187" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/franciscofsales"><img src="https://avatars.githubusercontent.com/u/7977645?v=4?s=100" width="100px;" alt="Francisco Sales"/><br /><sub><b>Francisco Sales</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=franciscofsales" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/myselfolli"><img src="https://avatars.githubusercontent.com/u/37535998?v=4?s=100" width="100px;" alt="Oliver Laing"/><br /><sub><b>Oliver Laing</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=myselfolli" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/M0NsTeRRR"><img src="https://avatars.githubusercontent.com/u/37785089?v=4?s=100" width="100px;" alt="Ludovic Ortega"/><br /><sub><b>Ludovic Ortega</b></sub></a><br /><a href="#security-M0NsTeRRR" title="Security">🛡️</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
version: '3'
|
||||
services:
|
||||
jellyseerr:
|
||||
build:
|
||||
@@ -75,6 +75,7 @@
|
||||
"types": 0,
|
||||
"options": {
|
||||
"webhookUrl": "",
|
||||
"webhookRoleId": "",
|
||||
"enableMentions": true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -95,6 +95,8 @@ location ^~ /jellyseerr {
|
||||
sub_filter '/api/v1' '/$app/api/v1';
|
||||
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
||||
sub_filter '/images/' '/$app/images/';
|
||||
sub_filter '/imageproxy/' '/$app/imageproxy/';
|
||||
sub_filter '/avatarproxy/' '/$app/avatarproxy/';
|
||||
sub_filter '/android-' '/$app/android-';
|
||||
sub_filter '/apple-' '/$app/apple-';
|
||||
sub_filter '/favicon' '/$app/favicon';
|
||||
@@ -190,7 +192,7 @@ Caddy will automatically obtain and renew SSL certificates for your domain.
|
||||
|
||||
## 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
|
||||
labels:
|
||||
|
||||
@@ -6,6 +6,10 @@ sidebar_position: 4
|
||||
|
||||
# AUR (Arch User Repository)
|
||||
|
||||
:::note Disclaimer
|
||||
This AUR package is not maintained by us but by a third party. Please refer to the maintainer for any issues.
|
||||
:::
|
||||
|
||||
:::info
|
||||
This method is not recommended for most users. It is intended for advanced users who are using Arch Linux or an Arch-based distribution.
|
||||
:::
|
||||
|
||||
@@ -12,49 +12,12 @@ import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
### Prerequisites
|
||||
<Tabs groupId="versions" queryString>
|
||||
<TabItem value="latest" label="Latest">
|
||||
- [Node.js 18.x](https://nodejs.org/en/download/)
|
||||
- [Yarn 1.x](https://classic.yarnpkg.com/lang/en/docs/install)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="develop" label="Develop">
|
||||
- [Node.js 20.x](https://nodejs.org/en/download/)
|
||||
- [Pnpm 9.x](https://pnpm.io/installation)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
## Unix (Linux, macOS)
|
||||
### Installation
|
||||
<Tabs groupId="versions" queryString>
|
||||
<TabItem value="latest" label="latest">
|
||||
1. Assuming you want the working directory to be `/opt/jellyseerr`, create the directory and navigate to it:
|
||||
```bash
|
||||
sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr
|
||||
```
|
||||
2. Clone the Jellyseerr repository and checkout the latest release:
|
||||
```bash
|
||||
git clone https://github.com/Fallenbagel/jellyseerr.git
|
||||
cd jellyseerr
|
||||
git checkout main
|
||||
```
|
||||
3. Install the dependencies:
|
||||
```bash
|
||||
CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000
|
||||
```
|
||||
4. Build the project:
|
||||
```bash
|
||||
yarn build
|
||||
```
|
||||
5. Start Jellyseerr:
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="develop" label="develop">
|
||||
1. Assuming you want the working directory to be `/opt/jellyseerr`, create the directory and navigate to it:
|
||||
```bash
|
||||
sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr
|
||||
@@ -77,8 +40,6 @@ pnpm build
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::info
|
||||
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
|
||||
@@ -234,33 +195,6 @@ pm2 status jellyseerr
|
||||
|
||||
## Windows
|
||||
### Installation
|
||||
<Tabs groupId="versions" queryString>
|
||||
<TabItem value="latest" label="latest">
|
||||
1. Assuming you want the working directory to be `C:\jellyseerr`, create the directory and navigate to it:
|
||||
```powershell
|
||||
mkdir C:\jellyseerr
|
||||
cd C:\jellyseerr
|
||||
```
|
||||
2. Clone the Jellyseerr repository and checkout the latest release:
|
||||
```powershell
|
||||
git clone https://github.com/Fallenbagel/jellyseerr.git .
|
||||
git checkout main
|
||||
```
|
||||
3. Install the dependencies:
|
||||
```powershell
|
||||
npm install -g win-node-env
|
||||
set CYPRESS_INSTALL_BINARY=0 && yarn install --frozen-lockfile --network-timeout 1000000
|
||||
```
|
||||
4. Build the project:
|
||||
```powershell
|
||||
yarn build
|
||||
```
|
||||
5. Start Jellyseerr:
|
||||
```powershell
|
||||
yarn start
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="develop" label="develop">
|
||||
1. Assuming you want the working directory to be `C:\jellyseerr`, create the directory and navigate to it:
|
||||
```powershell
|
||||
mkdir C:\jellyseerr
|
||||
@@ -284,8 +218,6 @@ pnpm build
|
||||
```powershell
|
||||
pnpm start
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::tip
|
||||
You can add the environment variables to a `.env` file in the Jellyseerr directory.
|
||||
@@ -313,6 +245,7 @@ node dist/index.js
|
||||
- Set the trigger to "When the computer starts"
|
||||
- Set the action to "Start a program"
|
||||
- Set the program/script to the path of the `start-jellyseerr.bat` file
|
||||
- Set the "Start in" to the jellyseerr directory.
|
||||
- Click "Finish"
|
||||
|
||||
Now, Jellyseerr will start when the computer boots up in the background.
|
||||
|
||||
@@ -71,7 +71,7 @@ You could also use [diun](https://github.com/crazy-max/diun) to receive notifica
|
||||
For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/).
|
||||
|
||||
#### Installation:
|
||||
Define the `jellyseerr` service in your `docker-compose.yml` as follows:
|
||||
Define the `jellyseerr` service in your `compose.yaml` as follows:
|
||||
```yaml
|
||||
---
|
||||
services:
|
||||
@@ -94,17 +94,17 @@ If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable
|
||||
|
||||
Then, start all services defined in the Compose file:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### Updating:
|
||||
Pull the latest image:
|
||||
```bash
|
||||
docker-compose pull jellyseerr
|
||||
docker compose pull jellyseerr
|
||||
```
|
||||
Then, restart all services defined in the Compose file:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
docker compose up -d
|
||||
```
|
||||
:::tip
|
||||
You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files.
|
||||
|
||||
@@ -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 → Integrations → 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)
|
||||
|
||||
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!
|
||||
|
||||
@@ -10,6 +10,7 @@ module.exports = {
|
||||
remotePatterns: [
|
||||
{ hostname: 'gravatar.com' },
|
||||
{ hostname: 'image.tmdb.org' },
|
||||
{ hostname: 'artworks.thetvdb.com' },
|
||||
],
|
||||
},
|
||||
webpack(config) {
|
||||
|
||||
@@ -1273,6 +1273,8 @@ components:
|
||||
type: string
|
||||
webhookUrl:
|
||||
type: string
|
||||
webhookRoleId:
|
||||
type: string
|
||||
enableMentions:
|
||||
type: boolean
|
||||
SlackSettings:
|
||||
@@ -1930,6 +1932,11 @@ components:
|
||||
type: string
|
||||
native_name:
|
||||
type: string
|
||||
OverrideRule:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
securitySchemes:
|
||||
cookieAuth:
|
||||
type: apiKey
|
||||
@@ -1988,6 +1995,9 @@ paths:
|
||||
appDataPath:
|
||||
type: string
|
||||
example: /app/config
|
||||
appDataPermissions:
|
||||
type: boolean
|
||||
example: true
|
||||
/settings/main:
|
||||
get:
|
||||
summary: Get main settings
|
||||
@@ -3750,6 +3760,11 @@ paths:
|
||||
type: string
|
||||
enum: [created, updated, requests, displayname]
|
||||
default: created
|
||||
- in: query
|
||||
name: q
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: A JSON array of all users
|
||||
@@ -4139,6 +4154,21 @@ paths:
|
||||
'412':
|
||||
description: Item has already been blacklisted
|
||||
/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:
|
||||
summary: Remove media from blacklist
|
||||
tags:
|
||||
@@ -6936,6 +6966,68 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
$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:
|
||||
- cookieAuth: []
|
||||
- apiKey: []
|
||||
|
||||
@@ -93,7 +93,8 @@
|
||||
"sqlite3": "5.1.4",
|
||||
"swagger-ui-express": "4.6.2",
|
||||
"swr": "2.2.5",
|
||||
"typeorm": "0.3.12",
|
||||
"typeorm": "0.3.11",
|
||||
"undici": "^6.20.1",
|
||||
"web-push": "3.5.0",
|
||||
"winston": "3.8.2",
|
||||
"winston-daily-rotate-file": "4.7.1",
|
||||
|
||||
65
pnpm-lock.yaml
generated
65
pnpm-lock.yaml
generated
@@ -49,7 +49,7 @@ importers:
|
||||
version: 2.11.0
|
||||
connect-typeorm:
|
||||
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:
|
||||
specifier: 1.4.6
|
||||
version: 1.4.6
|
||||
@@ -192,8 +192,11 @@ importers:
|
||||
specifier: 2.2.5
|
||||
version: 2.2.5(react@18.3.1)
|
||||
typeorm:
|
||||
specifier: 0.3.12
|
||||
version: 0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
|
||||
specifier: 0.3.11
|
||||
version: 0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
|
||||
undici:
|
||||
specifier: ^6.20.1
|
||||
version: 6.20.1
|
||||
web-push:
|
||||
specifier: 3.5.0
|
||||
version: 3.5.0
|
||||
@@ -4264,10 +4267,6 @@ packages:
|
||||
resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==}
|
||||
engines: {node: '>=0.11'}
|
||||
|
||||
date-fns@2.30.0:
|
||||
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
|
||||
engines: {node: '>=0.11'}
|
||||
|
||||
dateformat@3.0.3:
|
||||
resolution: {integrity: sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==}
|
||||
|
||||
@@ -5389,8 +5388,8 @@ packages:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
https-proxy-agent@7.0.4:
|
||||
resolution: {integrity: sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==}
|
||||
https-proxy-agent@7.0.5:
|
||||
resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
human-signals@1.1.1:
|
||||
@@ -6554,11 +6553,6 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
mkdirp@2.1.6:
|
||||
resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
modify-values@1.0.1:
|
||||
resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -7730,9 +7724,6 @@ packages:
|
||||
reflect-metadata@0.1.13:
|
||||
resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==}
|
||||
|
||||
reflect-metadata@0.1.14:
|
||||
resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==}
|
||||
|
||||
reflect.getprototypeof@1.0.6:
|
||||
resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -8670,8 +8661,8 @@ packages:
|
||||
typedarray@0.0.6:
|
||||
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||
|
||||
typeorm@0.3.12:
|
||||
resolution: {integrity: sha512-sYSxBmCf1nJLLTcYtwqZ+lQIRtLPyUoO93rHTOKk9vJCyT4UfRtU7oRsJvfvKP3nnZTD1hzz2SEy2zwPEN6OyA==}
|
||||
typeorm@0.3.11:
|
||||
resolution: {integrity: sha512-pzdOyWbVuz/z8Ww6gqvBW4nylsM0KLdUCDExr2gR20/x1khGSVxQkjNV/3YqliG90jrWzrknYbYscpk8yxFJVg==}
|
||||
engines: {node: '>= 12.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -8682,7 +8673,7 @@ packages:
|
||||
ioredis: ^5.0.4
|
||||
mongodb: ^3.6.0
|
||||
mssql: ^7.3.0
|
||||
mysql2: ^2.2.5 || ^3.0.1
|
||||
mysql2: ^2.2.5
|
||||
oracledb: ^5.1.0
|
||||
pg: ^8.5.1
|
||||
pg-native: ^3.0.0
|
||||
@@ -8768,6 +8759,10 @@ packages:
|
||||
undici-types@5.26.5:
|
||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||
|
||||
undici@6.20.1:
|
||||
resolution: {integrity: sha512-AjQF1QsmqfJys+LXfGTNum+qw4S88CojRInG/6t31W/1fk6G59s92bnAvGz5Cmur+kQv2SURXEvvudLmbrE8QA==}
|
||||
engines: {node: '>=18.17'}
|
||||
|
||||
unicode-canonical-property-names-ecmascript@2.0.0:
|
||||
resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -12310,7 +12305,7 @@ snapshots:
|
||||
fs-extra: 11.2.0
|
||||
globby: 11.1.0
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.4
|
||||
https-proxy-agent: 7.0.5
|
||||
issue-parser: 6.0.0
|
||||
lodash: 4.17.21
|
||||
mime: 3.0.0
|
||||
@@ -13824,13 +13819,13 @@ snapshots:
|
||||
ini: 1.3.8
|
||||
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:
|
||||
'@types/debug': 0.0.31
|
||||
'@types/express-session': 1.17.6
|
||||
debug: 4.3.5(supports-color@8.1.1)
|
||||
express-session: 1.18.0
|
||||
typeorm: 0.3.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:
|
||||
- supports-color
|
||||
|
||||
@@ -14181,10 +14176,6 @@ snapshots:
|
||||
|
||||
date-fns@2.29.3: {}
|
||||
|
||||
date-fns@2.30.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.24.7
|
||||
|
||||
dateformat@3.0.3: {}
|
||||
|
||||
dayjs@1.11.11: {}
|
||||
@@ -15739,7 +15730,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
https-proxy-agent@7.0.4:
|
||||
https-proxy-agent@7.0.5:
|
||||
dependencies:
|
||||
agent-base: 7.1.1
|
||||
debug: 4.3.5(supports-color@8.1.1)
|
||||
@@ -17149,8 +17140,6 @@ snapshots:
|
||||
|
||||
mkdirp@1.0.4: {}
|
||||
|
||||
mkdirp@2.1.6: {}
|
||||
|
||||
modify-values@1.0.1: {}
|
||||
|
||||
moment@2.30.1: {}
|
||||
@@ -18372,8 +18361,6 @@ snapshots:
|
||||
|
||||
reflect-metadata@0.1.13: {}
|
||||
|
||||
reflect-metadata@0.1.14: {}
|
||||
|
||||
reflect.getprototypeof@1.0.6:
|
||||
dependencies:
|
||||
call-bind: 1.0.7
|
||||
@@ -19431,23 +19418,23 @@ snapshots:
|
||||
|
||||
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:
|
||||
'@sqltools/formatter': 1.2.5
|
||||
app-root-path: 3.1.0
|
||||
buffer: 6.0.3
|
||||
chalk: 4.1.2
|
||||
cli-highlight: 2.1.11
|
||||
date-fns: 2.30.0
|
||||
date-fns: 2.29.3
|
||||
debug: 4.3.5(supports-color@8.1.1)
|
||||
dotenv: 16.4.5
|
||||
glob: 8.1.0
|
||||
glob: 7.2.3
|
||||
js-yaml: 4.1.0
|
||||
mkdirp: 2.1.6
|
||||
reflect-metadata: 0.1.14
|
||||
mkdirp: 1.0.4
|
||||
reflect-metadata: 0.1.13
|
||||
sha.js: 2.4.11
|
||||
tslib: 2.6.3
|
||||
uuid: 9.0.1
|
||||
uuid: 8.3.2
|
||||
xml2js: 0.4.23
|
||||
yargs: 17.7.2
|
||||
optionalDependencies:
|
||||
@@ -19486,6 +19473,8 @@ snapshots:
|
||||
|
||||
undici-types@5.26.5: {}
|
||||
|
||||
undici@6.20.1: {}
|
||||
|
||||
unicode-canonical-property-names-ecmascript@2.0.0: {}
|
||||
|
||||
unicode-emoji-utils@1.2.0:
|
||||
|
||||
@@ -32,13 +32,27 @@ class ExternalAPI {
|
||||
this.fetch = fetch;
|
||||
}
|
||||
|
||||
this.baseUrl = baseUrl;
|
||||
this.params = params;
|
||||
const url = new URL(baseUrl);
|
||||
|
||||
this.defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...((url.username || url.password) && {
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
`${url.username}:${url.password}`
|
||||
).toString('base64')}`,
|
||||
}),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (url.username || url.password) {
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
baseUrl = url.toString();
|
||||
}
|
||||
|
||||
this.baseUrl = baseUrl;
|
||||
this.params = params;
|
||||
this.cache = options.nodeCache;
|
||||
}
|
||||
|
||||
|
||||
@@ -138,39 +138,38 @@ class JellyfinAPI extends ExternalAPI {
|
||||
try {
|
||||
return await authenticate(true);
|
||||
} catch (e) {
|
||||
logger.debug(`Failed to authenticate with headers: ${e.message}`, {
|
||||
logger.debug('Failed to authenticate with headers', {
|
||||
label: 'Jellyfin API',
|
||||
error: e.cause.message ?? e.cause.statusText,
|
||||
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 {
|
||||
return await authenticate(false);
|
||||
} catch (e) {
|
||||
const status = e.cause?.status;
|
||||
|
||||
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);
|
||||
if (e.cause.status === 401) {
|
||||
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
|
||||
}
|
||||
|
||||
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;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
|
||||
{ label: 'Jellyfin API' }
|
||||
'Something went wrong while getting the server name from the Jellyfin server',
|
||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||
);
|
||||
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
|
||||
@@ -213,8 +212,8 @@ class JellyfinAPI extends ExternalAPI {
|
||||
return { users: userReponse };
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||
{ label: 'Jellyfin API' }
|
||||
'Something went wrong while getting the account from the Jellyfin server',
|
||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||
);
|
||||
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||
@@ -229,8 +228,8 @@ class JellyfinAPI extends ExternalAPI {
|
||||
return userReponse;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||
{ label: 'Jellyfin API' }
|
||||
'Something went wrong while getting the account from the Jellyfin server',
|
||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||
);
|
||||
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||
@@ -253,8 +252,11 @@ class JellyfinAPI extends ExternalAPI {
|
||||
return this.mapLibraries(mediaFolderResponse.Items);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
|
||||
{ label: 'Jellyfin API' }
|
||||
'Something went wrong while getting libraries from the Jellyfin server',
|
||||
{
|
||||
label: 'Jellyfin API',
|
||||
error: e.cause.message ?? e.cause.statusText,
|
||||
}
|
||||
);
|
||||
|
||||
return [];
|
||||
@@ -308,8 +310,8 @@ class JellyfinAPI extends ExternalAPI {
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||
{ label: 'Jellyfin API' }
|
||||
'Something went wrong while getting library content from the Jellyfin server',
|
||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||
);
|
||||
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||
@@ -329,8 +331,8 @@ class JellyfinAPI extends ExternalAPI {
|
||||
return itemResponse;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||
{ label: 'Jellyfin API' }
|
||||
'Something went wrong while getting library content from the Jellyfin server',
|
||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||
);
|
||||
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||
@@ -354,8 +356,8 @@ class JellyfinAPI extends ExternalAPI {
|
||||
}
|
||||
|
||||
logger.error(
|
||||
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||
{ label: 'Jellyfin API' }
|
||||
'Something went wrong while getting library content from the Jellyfin server',
|
||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||
);
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||
}
|
||||
@@ -368,8 +370,8 @@ class JellyfinAPI extends ExternalAPI {
|
||||
return seasonResponse.Items;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
|
||||
{ label: 'Jellyfin API' }
|
||||
'Something went wrong while getting the list of seasons from the Jellyfin server',
|
||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||
);
|
||||
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||
@@ -393,8 +395,8 @@ class JellyfinAPI extends ExternalAPI {
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
|
||||
{ label: 'Jellyfin API' }
|
||||
'Something went wrong while getting the list of episodes from the Jellyfin server',
|
||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||
);
|
||||
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||
@@ -410,8 +412,8 @@ class JellyfinAPI extends ExternalAPI {
|
||||
).AccessToken;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while creating an API key the Jellyfin server: ${e.message}`,
|
||||
{ label: 'Jellyfin API' }
|
||||
'Something went wrong while creating an API key from the Jellyfin server',
|
||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||
|
||||
@@ -180,7 +180,7 @@ class PlexAPI {
|
||||
settings.plex.libraries = [];
|
||||
}
|
||||
|
||||
settings.save();
|
||||
await settings.save();
|
||||
}
|
||||
|
||||
public async getLibraryContents(
|
||||
|
||||
@@ -182,7 +182,7 @@ class RottenTomatoes extends ExternalAPI {
|
||||
);
|
||||
}
|
||||
|
||||
if (!tvshow) {
|
||||
if (!tvshow || !tvshow.rottenTomatoes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,12 +80,12 @@ export class Blacklist implements BlacklistItem {
|
||||
status: MediaStatus.BLACKLISTED,
|
||||
status4k: MediaStatus.BLACKLISTED,
|
||||
mediaType: blacklistRequest.mediaType,
|
||||
blacklist: blacklist,
|
||||
blacklist: Promise.resolve(blacklist),
|
||||
});
|
||||
|
||||
await mediaRepository.save(media);
|
||||
} else {
|
||||
media.blacklist = blacklist;
|
||||
media.blacklist = Promise.resolve(blacklist);
|
||||
media.status = MediaStatus.BLACKLISTED;
|
||||
media.status4k = MediaStatus.BLACKLISTED;
|
||||
|
||||
|
||||
@@ -118,10 +118,8 @@ class Media {
|
||||
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
|
||||
public issues: Issue[];
|
||||
|
||||
@OneToOne(() => Blacklist, (blacklist) => blacklist.media, {
|
||||
eager: true,
|
||||
})
|
||||
public blacklist: Blacklist;
|
||||
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
|
||||
public blacklist: Promise<Blacklist>;
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
@@ -231,7 +229,7 @@ class Media {
|
||||
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
||||
}
|
||||
if (this.jellyfinMediaId4k) {
|
||||
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
||||
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
MediaType,
|
||||
} from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import OverrideRule from '@server/entity/OverrideRule';
|
||||
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
|
||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
@@ -713,48 +714,6 @@ export class MediaRequest {
|
||||
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 radarr = new RadarrAPI({
|
||||
apiKey: radarrSettings.apiKey,
|
||||
@@ -775,6 +734,151 @@ export class MediaRequest {
|
||||
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) {
|
||||
let userTag = (await radarr.getTags()).find((v) =>
|
||||
v.label.startsWith(this.requestedBy.id + ' - ')
|
||||
@@ -816,7 +920,6 @@ export class MediaRequest {
|
||||
mediaId: this.media.id,
|
||||
});
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
this.status = MediaRequestStatus.APPROVED;
|
||||
await requestRepository.save(this);
|
||||
return;
|
||||
@@ -856,8 +959,6 @@ export class MediaRequest {
|
||||
await mediaRepository.save(media);
|
||||
})
|
||||
.catch(async () => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
this.status = MediaRequestStatus.FAILED;
|
||||
requestRepository.save(this);
|
||||
|
||||
@@ -957,6 +1058,7 @@ export class MediaRequest {
|
||||
throw new Error('Media data not found');
|
||||
}
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
if (
|
||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
) {
|
||||
@@ -966,7 +1068,6 @@ export class MediaRequest {
|
||||
mediaId: this.media.id,
|
||||
});
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
this.status = MediaRequestStatus.APPROVED;
|
||||
await requestRepository.save(this);
|
||||
return;
|
||||
@@ -981,7 +1082,6 @@ export class MediaRequest {
|
||||
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
||||
|
||||
if (!tvdbId) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
await mediaRepository.remove(media);
|
||||
await requestRepository.remove(this);
|
||||
throw new Error('TVDB ID not found');
|
||||
@@ -1019,29 +1119,110 @@ export class MediaRequest {
|
||||
? [...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 (
|
||||
this.rootFolder &&
|
||||
this.rootFolder !== '' &&
|
||||
this.rootFolder !== 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(
|
||||
`Request has an override quality profile ID: ${qualityProfile}`,
|
||||
`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 !== 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 (
|
||||
@@ -1061,12 +1242,31 @@ export class MediaRequest {
|
||||
|
||||
if (this.tags && !isEqual(this.tags, tags)) {
|
||||
tags = this.tags;
|
||||
logger.info(`Request has override 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (sonarrSettings.tagRequests) {
|
||||
@@ -1101,6 +1301,8 @@ export class MediaRequest {
|
||||
}
|
||||
}
|
||||
|
||||
requestRepository.save(this);
|
||||
|
||||
const sonarrSeriesOptions: AddSeriesOptions = {
|
||||
profileId: qualityProfile,
|
||||
languageProfileId: languageProfile,
|
||||
@@ -1137,8 +1339,6 @@ export class MediaRequest {
|
||||
await mediaRepository.save(media);
|
||||
})
|
||||
.catch(async () => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
this.status = MediaRequestStatus.FAILED;
|
||||
requestRepository.save(this);
|
||||
|
||||
|
||||
52
server/entity/OverrideRule.ts
Normal file
52
server/entity/OverrideRule.ts
Normal 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;
|
||||
@@ -21,7 +21,9 @@ import clearCookies from '@server/middleware/clearcookies';
|
||||
import routes from '@server/routes';
|
||||
import avatarproxy from '@server/routes/avatarproxy';
|
||||
import imageproxy from '@server/routes/imageproxy';
|
||||
import { appDataPermissions } from '@server/utils/appDataVolume';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import createCustomProxyAgent from '@server/utils/customProxyAgent';
|
||||
import restartFlag from '@server/utils/restartFlag';
|
||||
import { getClientIp } from '@supercharge/request-ip';
|
||||
import { TypeormStore } from 'connect-typeorm/out';
|
||||
@@ -51,6 +53,12 @@ const dev = process.env.NODE_ENV !== 'production';
|
||||
const app = next({ dev });
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
if (!appDataPermissions()) {
|
||||
logger.error(
|
||||
'Something went wrong while checking config folder! Please ensure the config folder is set up properly.\nhttps://docs.jellyseerr.dev/getting-started'
|
||||
);
|
||||
}
|
||||
|
||||
app
|
||||
.prepare()
|
||||
.then(async () => {
|
||||
@@ -67,6 +75,11 @@ app
|
||||
const settings = await getSettings().load();
|
||||
restartFlag.initializeSettings(settings.main);
|
||||
|
||||
// Register HTTP proxy
|
||||
if (settings.main.proxy.enabled) {
|
||||
await createCustomProxyAgent(settings.main.proxy);
|
||||
}
|
||||
|
||||
// Migrate library types
|
||||
if (
|
||||
settings.plex.libraries.length > 1 &&
|
||||
|
||||
3
server/interfaces/api/overrideRuleInterfaces.ts
Normal file
3
server/interfaces/api/overrideRuleInterfaces.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type OverrideRule from '@server/entity/OverrideRule';
|
||||
|
||||
export type OverrideRuleResultsResponse = OverrideRule[];
|
||||
@@ -135,6 +135,7 @@ class ImageProxy {
|
||||
private cacheVersion;
|
||||
private key;
|
||||
private baseUrl;
|
||||
private headers: HeadersInit | null = null;
|
||||
|
||||
constructor(
|
||||
key: string,
|
||||
@@ -142,6 +143,7 @@ class ImageProxy {
|
||||
options: {
|
||||
cacheVersion?: number;
|
||||
rateLimitOptions?: RateLimitOptions;
|
||||
headers?: HeadersInit;
|
||||
} = {}
|
||||
) {
|
||||
this.cacheVersion = options.cacheVersion ?? 1;
|
||||
@@ -155,9 +157,13 @@ class ImageProxy {
|
||||
} else {
|
||||
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 imageResponse = await this.get(cacheKey);
|
||||
@@ -166,7 +172,11 @@ class ImageProxy {
|
||||
const newImage = await this.set(path, cacheKey);
|
||||
|
||||
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;
|
||||
@@ -247,7 +257,12 @@ class ImageProxy {
|
||||
: '/'
|
||||
: '') +
|
||||
(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 buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
|
||||
@@ -291,6 +291,10 @@ class DiscordAgent
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.options.webhookRoleId) {
|
||||
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
|
||||
}
|
||||
|
||||
const response = await fetch(settings.options.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -129,7 +129,7 @@ class PlexScanner
|
||||
});
|
||||
|
||||
settings.plex.libraries = newLibraries;
|
||||
settings.save();
|
||||
await settings.save();
|
||||
}
|
||||
} else {
|
||||
for (const library of this.libraries) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { MediaServerType } from '@server/constants/server';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { runMigrations } from '@server/lib/settings/migrator';
|
||||
import { randomUUID } from 'crypto';
|
||||
import fs from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import { merge } from 'lodash';
|
||||
import path from 'path';
|
||||
import webpush from 'web-push';
|
||||
@@ -76,6 +76,7 @@ export interface DVRSettings {
|
||||
syncEnabled: boolean;
|
||||
preventSearch: boolean;
|
||||
tagRequests: boolean;
|
||||
overrideRule: number[];
|
||||
}
|
||||
|
||||
export interface RadarrSettings extends DVRSettings {
|
||||
@@ -99,6 +100,17 @@ interface Quota {
|
||||
quotaDays?: number;
|
||||
}
|
||||
|
||||
export interface ProxySettings {
|
||||
enabled: boolean;
|
||||
hostname: string;
|
||||
port: number;
|
||||
useSsl: boolean;
|
||||
user: string;
|
||||
password: string;
|
||||
bypassFilter: string;
|
||||
bypassLocalAddresses: boolean;
|
||||
}
|
||||
|
||||
export interface MainSettings {
|
||||
apiKey: string;
|
||||
applicationTitle: string;
|
||||
@@ -119,6 +131,7 @@ export interface MainSettings {
|
||||
mediaServerType: number;
|
||||
partialRequestsEnabled: boolean;
|
||||
locale: string;
|
||||
proxy: ProxySettings;
|
||||
}
|
||||
|
||||
interface PublicSettings {
|
||||
@@ -158,6 +171,7 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig {
|
||||
botUsername?: string;
|
||||
botAvatarUrl?: string;
|
||||
webhookUrl: string;
|
||||
webhookRoleId?: string;
|
||||
enableMentions: boolean;
|
||||
};
|
||||
}
|
||||
@@ -325,6 +339,16 @@ class Settings {
|
||||
mediaServerType: MediaServerType.NOT_CONFIGURED,
|
||||
partialRequestsEnabled: true,
|
||||
locale: 'en',
|
||||
proxy: {
|
||||
enabled: false,
|
||||
hostname: '',
|
||||
port: 8080,
|
||||
useSsl: false,
|
||||
user: '',
|
||||
password: '',
|
||||
bypassFilter: '',
|
||||
bypassLocalAddresses: true,
|
||||
},
|
||||
},
|
||||
plex: {
|
||||
name: '',
|
||||
@@ -372,6 +396,7 @@ class Settings {
|
||||
types: 0,
|
||||
options: {
|
||||
webhookUrl: '',
|
||||
webhookRoleId: '',
|
||||
enableMentions: true,
|
||||
},
|
||||
},
|
||||
@@ -479,10 +504,6 @@ class Settings {
|
||||
}
|
||||
|
||||
get main(): MainSettings {
|
||||
if (!this.data.main.apiKey) {
|
||||
this.data.main.apiKey = this.generateApiKey();
|
||||
this.save();
|
||||
}
|
||||
return this.data.main;
|
||||
}
|
||||
|
||||
@@ -584,29 +605,20 @@ class Settings {
|
||||
}
|
||||
|
||||
get clientId(): string {
|
||||
if (!this.data.clientId) {
|
||||
this.data.clientId = randomUUID();
|
||||
this.save();
|
||||
}
|
||||
|
||||
return this.data.clientId;
|
||||
}
|
||||
|
||||
get vapidPublic(): string {
|
||||
this.generateVapidKeys();
|
||||
|
||||
return this.data.vapidPublic;
|
||||
}
|
||||
|
||||
get vapidPrivate(): string {
|
||||
this.generateVapidKeys();
|
||||
|
||||
return this.data.vapidPrivate;
|
||||
}
|
||||
|
||||
public regenerateApiKey(): MainSettings {
|
||||
public async regenerateApiKey(): Promise<MainSettings> {
|
||||
this.main.apiKey = this.generateApiKey();
|
||||
this.save();
|
||||
await this.save();
|
||||
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
|
||||
*
|
||||
@@ -641,30 +644,51 @@ class Settings {
|
||||
return this;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(SETTINGS_PATH)) {
|
||||
this.save();
|
||||
let data;
|
||||
try {
|
||||
data = await fs.readFile(SETTINGS_PATH, 'utf-8');
|
||||
} catch {
|
||||
await this.save();
|
||||
}
|
||||
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
|
||||
if (data) {
|
||||
const parsedJson = JSON.parse(data);
|
||||
this.data = await runMigrations(parsedJson);
|
||||
|
||||
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();
|
||||
const migratedData = await runMigrations(parsedJson, SETTINGS_PATH);
|
||||
this.data = merge(this.data, migratedData);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
public save(): void {
|
||||
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(this.data, undefined, ' '));
|
||||
public async save(): Promise<void> {
|
||||
await fs.writeFile(
|
||||
SETTINGS_PATH,
|
||||
JSON.stringify(this.data, undefined, ' ')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { AllSettings } from '@server/lib/settings';
|
||||
|
||||
const migrateHostname = (settings: any): AllSettings => {
|
||||
const oldJellyfinSettings = settings.jellyfin;
|
||||
if (oldJellyfinSettings && oldJellyfinSettings.hostname) {
|
||||
const { hostname } = oldJellyfinSettings;
|
||||
if (settings.jellyfin?.hostname) {
|
||||
const { hostname } = settings.jellyfin;
|
||||
const protocolMatch = hostname.match(/^(https?):\/\//i);
|
||||
const useSsl = protocolMatch && protocolMatch[1].toLowerCase() === 'https';
|
||||
const remainingUrl = hostname.replace(/^(https?):\/\//i, '');
|
||||
const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/);
|
||||
|
||||
delete oldJellyfinSettings.hostname;
|
||||
delete settings.jellyfin.hostname;
|
||||
if (urlMatch) {
|
||||
const [, ip, , port, urlBase] = urlMatch;
|
||||
settings.jellyfin = {
|
||||
@@ -21,9 +20,7 @@ const migrateHostname = (settings: any): AllSettings => {
|
||||
};
|
||||
}
|
||||
}
|
||||
if (settings.jellyfin && settings.jellyfin.hostname) {
|
||||
delete settings.jellyfin.hostname;
|
||||
}
|
||||
|
||||
return settings;
|
||||
};
|
||||
|
||||
|
||||
@@ -27,8 +27,14 @@ const migrateApiTokens = async (settings: any): Promise<AllSettings> => {
|
||||
admin.jellyfinDeviceId
|
||||
);
|
||||
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
||||
const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
|
||||
settings.jellyfin.apiKey = apiKey;
|
||||
try {
|
||||
const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
|
||||
settings.jellyfin.apiKey = apiKey;
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Failed to create Jellyfin API token from admin account. Please check your network configuration or edit your settings.json by adding an 'apiKey' field inside of the 'jellyfin' section to fix this issue."
|
||||
);
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
};
|
||||
|
||||
@@ -1,30 +1,100 @@
|
||||
import type { AllSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import fs from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const migrationsDir = path.join(__dirname, 'migrations');
|
||||
|
||||
export const runMigrations = async (
|
||||
settings: AllSettings
|
||||
settings: AllSettings,
|
||||
SETTINGS_PATH: string
|
||||
): Promise<AllSettings> => {
|
||||
const migrations = fs
|
||||
.readdirSync(migrationsDir)
|
||||
.filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
.map((file) => require(path.join(migrationsDir, file)).default);
|
||||
|
||||
let migrated = settings;
|
||||
|
||||
try {
|
||||
// we read old backup and create a backup of currents settings
|
||||
const BACKUP_PATH = SETTINGS_PATH.replace('.json', '.old.json');
|
||||
let oldBackup: string | null = null;
|
||||
try {
|
||||
oldBackup = await fs.readFile(BACKUP_PATH, 'utf-8');
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
await fs.writeFile(BACKUP_PATH, JSON.stringify(settings, undefined, ' '));
|
||||
|
||||
const migrations = (await fs.readdir(migrationsDir)).filter(
|
||||
(file) => file.endsWith('.js') || file.endsWith('.ts')
|
||||
);
|
||||
|
||||
const settingsBefore = JSON.stringify(migrated);
|
||||
|
||||
for (const migration of migrations) {
|
||||
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);
|
||||
|
||||
if (settingsBefore !== settingsAfter) {
|
||||
// a migration occured
|
||||
// we check that the new config will be saved
|
||||
await fs.writeFile(
|
||||
SETTINGS_PATH,
|
||||
JSON.stringify(migrated, undefined, ' ')
|
||||
);
|
||||
const fileSaved = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
|
||||
if (JSON.stringify(fileSaved) !== settingsAfter) {
|
||||
// something went wrong while saving file
|
||||
throw new Error('Unable to save settings after migration.');
|
||||
}
|
||||
} else if (oldBackup) {
|
||||
// no migration occured
|
||||
// we save the old backup (to avoid settings.json and settings.old.json being the same)
|
||||
await fs.writeFile(BACKUP_PATH, oldBackup.toString());
|
||||
}
|
||||
} catch (e) {
|
||||
// we stop jellyseerr if the migration failed
|
||||
logger.error(
|
||||
`Something went wrong while running settings migrations: ${e.message}`,
|
||||
{ label: 'Settings Migrator' }
|
||||
{
|
||||
label: 'Settings Migrator',
|
||||
}
|
||||
);
|
||||
logger.error(
|
||||
'A common cause for this issue is a permission error of your configuration folder.',
|
||||
{
|
||||
label: 'Settings Migrator',
|
||||
}
|
||||
);
|
||||
process.exit();
|
||||
}
|
||||
|
||||
return migrated;
|
||||
|
||||
15
server/migration/1731963944025-AddOverrideRules.ts
Normal file
15
server/migration/1731963944025-AddOverrideRules.ts
Normal 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"`);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { UserType } from '@server/constants/user';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import { startJobs } from '@server/job/schedule';
|
||||
import ImageProxy from '@server/lib/imageproxy';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
@@ -15,7 +14,6 @@ import { ApiError } from '@server/types/error';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
import * as EmailValidator from 'email-validator';
|
||||
import { Router } from 'express';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
import net from 'net';
|
||||
|
||||
const authRoutes = Router();
|
||||
@@ -89,7 +87,7 @@ authRoutes.post('/plex', async (req, res, next) => {
|
||||
});
|
||||
|
||||
settings.main.mediaServerType = MediaServerType.PLEX;
|
||||
settings.save();
|
||||
await settings.save();
|
||||
startJobs();
|
||||
|
||||
await userRepository.save(user);
|
||||
@@ -262,8 +260,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
urlBase: body.urlBase,
|
||||
});
|
||||
|
||||
const { externalHostname } = getSettings().jellyfin;
|
||||
|
||||
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
|
||||
let user = await userRepository.findOne({
|
||||
where: { jellyfinUsername: body.username },
|
||||
@@ -281,11 +277,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
// First we need to attempt to log the user in to jellyfin
|
||||
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
|
||||
|
||||
const jellyfinHost =
|
||||
externalHostname && externalHostname.length > 0
|
||||
? externalHostname
|
||||
: hostname;
|
||||
|
||||
const ip = req.ip;
|
||||
let clientIp;
|
||||
|
||||
@@ -308,64 +299,84 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
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
|
||||
if (account.User.Policy.IsAdministrator === false) {
|
||||
throw new ApiError(403, ApiErrorCode.NotAdmin);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
if (
|
||||
body.serverType !== MediaServerType.JELLYFIN &&
|
||||
body.serverType !== MediaServerType.EMBY
|
||||
) {
|
||||
throw new Error('select_server_type');
|
||||
}
|
||||
settings.main.mediaServerType = body.serverType;
|
||||
|
||||
if (missingAdminUser) {
|
||||
logger.info(
|
||||
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Jellyseerr',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUsername: account.User.Name,
|
||||
}
|
||||
);
|
||||
|
||||
// User doesn't exist, and there are no users in the database, we'll create the user
|
||||
// with admin permissions
|
||||
|
||||
user = new User({
|
||||
id: 1,
|
||||
email: body.email || account.User.Name,
|
||||
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
|
||||
// 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
|
||||
? `${jellyfinHost}/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
|
||||
? `${jellyfinHost}/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');
|
||||
await userRepository.save(user);
|
||||
}
|
||||
|
||||
// Create an API key on Jellyfin from this admin user
|
||||
@@ -385,10 +396,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
settings.jellyfin.urlBase = body.urlBase ?? '';
|
||||
settings.jellyfin.useSsl = body.useSsl ?? false;
|
||||
settings.jellyfin.apiKey = apiKey;
|
||||
settings.save();
|
||||
await settings.save();
|
||||
startJobs();
|
||||
|
||||
await userRepository.save(user);
|
||||
}
|
||||
// User already exists, let's update their information
|
||||
else if (account.User.Id === user?.jellyfinUserId) {
|
||||
@@ -408,27 +417,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
jellyfinUsername: account.User.Name,
|
||||
}
|
||||
);
|
||||
// Update the users avatar with their jellyfin profile pic (incase it changed)
|
||||
if (account.User.PrimaryImageTag) {
|
||||
const avatar = `${jellyfinHost}/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.avatar = `/avatarproxy/${account.User.Id}`;
|
||||
user.jellyfinUsername = account.User.Name;
|
||||
|
||||
if (user.username === account.User.Name) {
|
||||
@@ -466,12 +455,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinDeviceId: deviceId,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
avatar: account.User.PrimaryImageTag
|
||||
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
||||
: gravatarUrl(body.email || account.User.Name, {
|
||||
default: 'mm',
|
||||
size: 200,
|
||||
}),
|
||||
avatar: `/avatarproxy/${account.User.Id}`,
|
||||
userType:
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? UserType.JELLYFIN
|
||||
|
||||
@@ -1,16 +1,71 @@
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import ImageProxy from '@server/lib/imageproxy';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
import { Router } from 'express';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const avatarImageProxy = new ImageProxy('avatar', '');
|
||||
// Proxy avatar images
|
||||
router.get('/*', async (req, res) => {
|
||||
const imagePath = req.url.startsWith('/') ? req.url.slice(1) : req.url;
|
||||
let _avatarImageProxy: ImageProxy | null = null;
|
||||
async function initAvatarImageProxy() {
|
||||
if (!_avatarImageProxy) {
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOne({
|
||||
where: { id: 1 },
|
||||
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
const deviceId = admin?.jellyfinDeviceId;
|
||||
const authToken = getSettings().jellyfin.apiKey;
|
||||
_avatarImageProxy = new ImageProxy('avatar', '', {
|
||||
headers: {
|
||||
'X-Emby-Authorization': `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return _avatarImageProxy;
|
||||
}
|
||||
|
||||
router.get('/:jellyfinUserId', async (req, res) => {
|
||||
try {
|
||||
const imageData = await avatarImageProxy.getImage(imagePath);
|
||||
if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) {
|
||||
const mediaServerType = getSettings().main.mediaServerType;
|
||||
throw new Error(
|
||||
`Provided URL is not ${
|
||||
mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'a Jellyfin'
|
||||
: 'an Emby'
|
||||
} avatar.`
|
||||
);
|
||||
}
|
||||
|
||||
const avatarImageCache = await initAvatarImageProxy();
|
||||
|
||||
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, {
|
||||
'Content-Type': `image/${imageData.meta.extension}`,
|
||||
@@ -23,7 +78,6 @@ router.get('/*', async (req, res) => {
|
||||
res.end(imageData.imageBuffer);
|
||||
} catch (e) {
|
||||
logger.error('Failed to proxy avatar image', {
|
||||
imagePath,
|
||||
errorMessage: e.message,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { Blacklist } from '@server/entity/Blacklist';
|
||||
import Media from '@server/entity/Media';
|
||||
import { NotFoundError } from '@server/entity/Watchlist';
|
||||
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { Router } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
import { EntityNotFoundError, QueryFailedError } from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const blacklistRoutes = Router();
|
||||
@@ -26,7 +24,6 @@ blacklistRoutes.get(
|
||||
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
|
||||
type: 'or',
|
||||
}),
|
||||
rateLimit({ windowMs: 60 * 1000, max: 50 }),
|
||||
async (req, res, next) => {
|
||||
const pageSize = req.query.take ? Number(req.query.take) : 25;
|
||||
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(
|
||||
'/',
|
||||
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
||||
@@ -134,7 +157,7 @@ blacklistRoutes.delete(
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
if (e instanceof NotFoundError) {
|
||||
if (e instanceof EntityNotFoundError) {
|
||||
return next({
|
||||
status: 401,
|
||||
message: e.message,
|
||||
|
||||
@@ -15,9 +15,14 @@ import { checkUser, isAuthenticated } from '@server/middleware/auth';
|
||||
import { mapWatchProviderDetails } from '@server/models/common';
|
||||
import { mapProductionCompany } from '@server/models/Movie';
|
||||
import { mapNetwork } from '@server/models/Tv';
|
||||
import overrideRuleRoutes from '@server/routes/overrideRule';
|
||||
import settingsRoutes from '@server/routes/settings';
|
||||
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 restartFlag from '@server/utils/restartFlag';
|
||||
import { isPerson } from '@server/utils/typeHelpers';
|
||||
@@ -93,6 +98,7 @@ router.get('/status/appdata', (_req, res) => {
|
||||
return res.status(200).json({
|
||||
appData: appDataStatus(),
|
||||
appDataPath: appDataPath(),
|
||||
appDataPermissions: appDataPermissions(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,6 +161,11 @@ router.use('/service', isAuthenticated(), serviceRoutes);
|
||||
router.use('/issue', isAuthenticated(), issueRoutes);
|
||||
router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
|
||||
router.use('/auth', authRoutes);
|
||||
router.use(
|
||||
'/overrideRule',
|
||||
isAuthenticated(Permission.ADMIN),
|
||||
overrideRuleRoutes
|
||||
);
|
||||
|
||||
router.get('/regions', isAuthenticated(), async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
136
server/routes/overrideRule.ts
Normal file
136
server/routes/overrideRule.ts
Normal 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;
|
||||
@@ -123,9 +123,13 @@ serviceRoutes.get<{ sonarrId: string }>(
|
||||
});
|
||||
|
||||
try {
|
||||
const systemStatus = await sonarr.getSystemStatus();
|
||||
const sonarrMajorVersion = Number(systemStatus.version.split('.')[0]);
|
||||
|
||||
const profiles = await sonarr.getProfiles();
|
||||
const rootFolders = await sonarr.getRootFolders();
|
||||
const languageProfiles = await sonarr.getLanguageProfiles();
|
||||
const languageProfiles =
|
||||
sonarrMajorVersion <= 3 ? await sonarr.getLanguageProfiles() : null;
|
||||
const tags = await sonarr.getTags();
|
||||
|
||||
return res.status(200).json({
|
||||
|
||||
@@ -32,7 +32,6 @@ import { getHostname } from '@server/utils/getHostname';
|
||||
import { Router } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import fs from 'fs';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
import { escapeRegExp, merge, omit, set, sortBy } from 'lodash';
|
||||
import { rescheduleJob } from 'node-schedule';
|
||||
import path from 'path';
|
||||
@@ -70,19 +69,19 @@ settingsRoutes.get('/main', (req, res, next) => {
|
||||
res.status(200).json(filteredMainSettings(req.user, settings.main));
|
||||
});
|
||||
|
||||
settingsRoutes.post('/main', (req, res) => {
|
||||
settingsRoutes.post('/main', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.main = merge(settings.main, req.body);
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
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 main = settings.regenerateApiKey();
|
||||
const main = await settings.regenerateApiKey();
|
||||
|
||||
if (!req.user) {
|
||||
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.name = result.MediaContainer.friendlyName;
|
||||
|
||||
settings.save();
|
||||
await settings.save();
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong testing Plex connection', {
|
||||
label: 'API',
|
||||
@@ -232,7 +231,7 @@ settingsRoutes.get('/plex/library', async (req, res) => {
|
||||
...library,
|
||||
enabled: enabledLibraries.includes(library.id),
|
||||
}));
|
||||
settings.save();
|
||||
await settings.save();
|
||||
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);
|
||||
settings.jellyfin.serverId = result.Id;
|
||||
settings.jellyfin.name = result.ServerName;
|
||||
settings.save();
|
||||
await settings.save();
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
logger.error('Something went wrong testing Jellyfin connection', {
|
||||
@@ -371,17 +370,12 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
||||
...library,
|
||||
enabled: enabledLibraries.includes(library.id),
|
||||
}));
|
||||
settings.save();
|
||||
await settings.save();
|
||||
return res.status(200).json(settings.jellyfin.libraries);
|
||||
});
|
||||
|
||||
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const { externalHostname } = settings.jellyfin;
|
||||
const jellyfinHost =
|
||||
externalHostname && externalHostname.length > 0
|
||||
? externalHostname
|
||||
: getHostname();
|
||||
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOneOrFail({
|
||||
@@ -400,9 +394,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
||||
const users = resp.users.map((user) => ({
|
||||
username: user.Name,
|
||||
id: user.Id,
|
||||
thumb: user.PrimaryImageTag
|
||||
? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
|
||||
: gravatarUrl(user.Name, { default: 'mm', size: 200 }),
|
||||
thumb: `/avatarproxy/${user.Id}`,
|
||||
email: user.Name,
|
||||
}));
|
||||
|
||||
@@ -442,7 +434,7 @@ settingsRoutes.post('/tautulli', async (req, res, next) => {
|
||||
throw new Error('Tautulli version not supported');
|
||||
}
|
||||
|
||||
settings.save();
|
||||
await settings.save();
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong testing Tautulli connection', {
|
||||
label: 'API',
|
||||
@@ -703,7 +695,7 @@ settingsRoutes.post<{ jobId: JobId }>(
|
||||
|
||||
settingsRoutes.post<{ jobId: JobId }>(
|
||||
'/jobs/:jobId/schedule',
|
||||
(req, res, next) => {
|
||||
async (req, res, next) => {
|
||||
const scheduledJob = scheduledJobs.find(
|
||||
(job) => job.id === req.params.jobId
|
||||
);
|
||||
@@ -717,7 +709,7 @@ settingsRoutes.post<{ jobId: JobId }>(
|
||||
|
||||
if (result) {
|
||||
settings.jobs[scheduledJob.id].schedule = req.body.schedule;
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
scheduledJob.cronSchedule = req.body.schedule;
|
||||
|
||||
@@ -774,11 +766,11 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
|
||||
settingsRoutes.post(
|
||||
'/initialize',
|
||||
isAuthenticated(Permission.ADMIN),
|
||||
(_req, res) => {
|
||||
async (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.public.initialized = true;
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
return res.status(200).json(settings.public);
|
||||
}
|
||||
|
||||
@@ -31,11 +31,11 @@ notificationRoutes.get('/discord', (_req, res) => {
|
||||
res.status(200).json(settings.notifications.agents.discord);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/discord', (req, res) => {
|
||||
notificationRoutes.post('/discord', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.discord = req.body;
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.discord);
|
||||
});
|
||||
@@ -65,11 +65,11 @@ notificationRoutes.get('/slack', (_req, res) => {
|
||||
res.status(200).json(settings.notifications.agents.slack);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/slack', (req, res) => {
|
||||
notificationRoutes.post('/slack', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.slack = req.body;
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.slack);
|
||||
});
|
||||
@@ -99,11 +99,11 @@ notificationRoutes.get('/telegram', (_req, res) => {
|
||||
res.status(200).json(settings.notifications.agents.telegram);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/telegram', (req, res) => {
|
||||
notificationRoutes.post('/telegram', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.telegram = req.body;
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.telegram);
|
||||
});
|
||||
@@ -133,11 +133,11 @@ notificationRoutes.get('/pushbullet', (_req, res) => {
|
||||
res.status(200).json(settings.notifications.agents.pushbullet);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/pushbullet', (req, res) => {
|
||||
notificationRoutes.post('/pushbullet', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.pushbullet = req.body;
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.pushbullet);
|
||||
});
|
||||
@@ -167,11 +167,11 @@ notificationRoutes.get('/pushover', (_req, res) => {
|
||||
res.status(200).json(settings.notifications.agents.pushover);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/pushover', (req, res) => {
|
||||
notificationRoutes.post('/pushover', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.pushover = req.body;
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.pushover);
|
||||
});
|
||||
@@ -201,11 +201,11 @@ notificationRoutes.get('/email', (_req, res) => {
|
||||
res.status(200).json(settings.notifications.agents.email);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/email', (req, res) => {
|
||||
notificationRoutes.post('/email', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.email = req.body;
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.email);
|
||||
});
|
||||
@@ -235,11 +235,11 @@ notificationRoutes.get('/webpush', (_req, res) => {
|
||||
res.status(200).json(settings.notifications.agents.webpush);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/webpush', (req, res) => {
|
||||
notificationRoutes.post('/webpush', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.webpush = req.body;
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.webpush);
|
||||
});
|
||||
@@ -284,7 +284,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
|
||||
res.status(200).json(response);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/webhook', (req, res, next) => {
|
||||
notificationRoutes.post('/webhook', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
try {
|
||||
JSON.parse(req.body.options.jsonPayload);
|
||||
@@ -300,7 +300,7 @@ notificationRoutes.post('/webhook', (req, res, next) => {
|
||||
authHeader: req.body.options.authHeader,
|
||||
},
|
||||
};
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.webhook);
|
||||
} catch (e) {
|
||||
@@ -351,11 +351,11 @@ notificationRoutes.get('/lunasea', (_req, res) => {
|
||||
res.status(200).json(settings.notifications.agents.lunasea);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/lunasea', (req, res) => {
|
||||
notificationRoutes.post('/lunasea', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.lunasea = req.body;
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.lunasea);
|
||||
});
|
||||
@@ -385,11 +385,11 @@ notificationRoutes.get('/gotify', (_req, res) => {
|
||||
res.status(200).json(settings.notifications.agents.gotify);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/gotify', (req, res) => {
|
||||
notificationRoutes.post('/gotify', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.gotify = req.body;
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.gotify);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ radarrRoutes.get('/', (_req, res) => {
|
||||
res.status(200).json(settings.radarr);
|
||||
});
|
||||
|
||||
radarrRoutes.post('/', (req, res) => {
|
||||
radarrRoutes.post('/', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const newRadarr = req.body as RadarrSettings;
|
||||
@@ -31,7 +31,7 @@ radarrRoutes.post('/', (req, res) => {
|
||||
}
|
||||
|
||||
settings.radarr = [...settings.radarr, newRadarr];
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
return res.status(201).json(newRadarr);
|
||||
});
|
||||
@@ -76,7 +76,7 @@ radarrRoutes.post<
|
||||
|
||||
radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>(
|
||||
'/:id',
|
||||
(req, res, next) => {
|
||||
async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const radarrIndex = settings.radarr.findIndex(
|
||||
@@ -102,7 +102,7 @@ radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>(
|
||||
...req.body,
|
||||
id: Number(req.params.id),
|
||||
} as RadarrSettings;
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
return res.status(200).json(settings.radarr[radarrIndex]);
|
||||
}
|
||||
@@ -134,7 +134,7 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => {
|
||||
);
|
||||
});
|
||||
|
||||
radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => {
|
||||
radarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const radarrIndex = settings.radarr.findIndex(
|
||||
@@ -146,7 +146,7 @@ radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => {
|
||||
}
|
||||
|
||||
const removed = settings.radarr.splice(radarrIndex, 1);
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
return res.status(200).json(removed[0]);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ sonarrRoutes.get('/', (_req, res) => {
|
||||
res.status(200).json(settings.sonarr);
|
||||
});
|
||||
|
||||
sonarrRoutes.post('/', (req, res) => {
|
||||
sonarrRoutes.post('/', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const newSonarr = req.body as SonarrSettings;
|
||||
@@ -31,7 +31,7 @@ sonarrRoutes.post('/', (req, res) => {
|
||||
}
|
||||
|
||||
settings.sonarr = [...settings.sonarr, newSonarr];
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
return res.status(201).json(newSonarr);
|
||||
});
|
||||
@@ -43,13 +43,14 @@ sonarrRoutes.post('/test', async (req, res, next) => {
|
||||
url: SonarrAPI.buildUrl(req.body, '/api/v3'),
|
||||
});
|
||||
|
||||
const urlBase = await sonarr
|
||||
.getSystemStatus()
|
||||
.then((value) => value.urlBase)
|
||||
.catch(() => req.body.baseUrl);
|
||||
const systemStatus = await sonarr.getSystemStatus();
|
||||
const sonarrMajorVersion = Number(systemStatus.version.split('.')[0]);
|
||||
|
||||
const urlBase = systemStatus.urlBase;
|
||||
const profiles = await sonarr.getProfiles();
|
||||
const folders = await sonarr.getRootFolders();
|
||||
const languageProfiles = await sonarr.getLanguageProfiles();
|
||||
const languageProfiles =
|
||||
sonarrMajorVersion <= 3 ? await sonarr.getLanguageProfiles() : null;
|
||||
const tags = await sonarr.getTags();
|
||||
|
||||
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 sonarrIndex = settings.sonarr.findIndex(
|
||||
@@ -100,12 +101,12 @@ sonarrRoutes.put<{ id: string }>('/:id', (req, res) => {
|
||||
...req.body,
|
||||
id: Number(req.params.id),
|
||||
} as SonarrSettings;
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
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 sonarrIndex = settings.sonarr.findIndex(
|
||||
@@ -119,7 +120,7 @@ sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
|
||||
}
|
||||
|
||||
const removed = settings.sonarr.splice(sonarrIndex, 1);
|
||||
settings.save();
|
||||
await settings.save();
|
||||
|
||||
return res.status(200).json(removed[0]);
|
||||
});
|
||||
|
||||
@@ -34,8 +34,16 @@ router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const pageSize = req.query.take ? Number(req.query.take) : 10;
|
||||
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');
|
||||
|
||||
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) {
|
||||
case 'updated':
|
||||
query = query.orderBy('user.updatedAt', 'DESC');
|
||||
@@ -516,12 +524,6 @@ router.post(
|
||||
|
||||
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
||||
const createdUsers: User[] = [];
|
||||
const { externalHostname } = getSettings().jellyfin;
|
||||
|
||||
const jellyfinHost =
|
||||
externalHostname && externalHostname.length > 0
|
||||
? externalHostname
|
||||
: hostname;
|
||||
|
||||
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
||||
const jellyfinUsers = await jellyfinClient.getUsers();
|
||||
@@ -545,12 +547,7 @@ router.post(
|
||||
).toString('base64'),
|
||||
email: jellyfinUser?.Name,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
avatar: jellyfinUser?.PrimaryImageTag
|
||||
? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
|
||||
: gravatarUrl(jellyfinUser?.Name ?? '', {
|
||||
default: 'mm',
|
||||
size: 200,
|
||||
}),
|
||||
avatar: `/avatarproxy/${jellyfinUser?.Id}`,
|
||||
userType:
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? UserType.JELLYFIN
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import { UserSettings } from '@server/entity/UserSettings';
|
||||
@@ -99,11 +100,29 @@ userSettingsRoutes.post<
|
||||
});
|
||||
}
|
||||
|
||||
user.username = req.body.username;
|
||||
const oldEmail = user.email;
|
||||
const oldUsername = user.username;
|
||||
user.username = req.body.username;
|
||||
if (user.jellyfinUsername) {
|
||||
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({
|
||||
where: { email: user.email },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync } from 'fs';
|
||||
import { accessSync, existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const CONFIG_PATH = process.env.CONFIG_DIRECTORY
|
||||
@@ -14,3 +14,12 @@ export const appDataStatus = (): boolean => {
|
||||
export const appDataPath = (): string => {
|
||||
return CONFIG_PATH;
|
||||
};
|
||||
|
||||
export const appDataPermissions = (): boolean => {
|
||||
try {
|
||||
accessSync(CONFIG_PATH);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
111
server/utils/customProxyAgent.ts
Normal file
111
server/utils/customProxyAgent.ts
Normal 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;
|
||||
}
|
||||
@@ -13,7 +13,8 @@ class RestartFlag {
|
||||
|
||||
return (
|
||||
this.settings.csrfProtection !== settings.csrfProtection ||
|
||||
this.settings.trustProxy !== settings.trustProxy
|
||||
this.settings.trustProxy !== settings.trustProxy ||
|
||||
this.settings.proxy.enabled !== settings.proxy.enabled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +268,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
||||
{title && title.backdropPath && (
|
||||
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
@@ -293,6 +294,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
||||
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={
|
||||
title?.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||
@@ -355,6 +357,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
||||
<Link href={`/users/${item.user.id}`}>
|
||||
<span className="group flex items-center truncate">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
src={item.user.avatar}
|
||||
alt=""
|
||||
className="avatar-sm ml-1.5"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
@@ -10,6 +11,7 @@ import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const messages = defineMessages('component.BlacklistBlock', {
|
||||
blacklistedby: 'Blacklisted By',
|
||||
@@ -17,13 +19,13 @@ const messages = defineMessages('component.BlacklistBlock', {
|
||||
});
|
||||
|
||||
interface BlacklistBlockProps {
|
||||
blacklistItem: Blacklist;
|
||||
tmdbId: number;
|
||||
onUpdate?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
const BlacklistBlock = ({
|
||||
blacklistItem,
|
||||
tmdbId,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: BlacklistBlockProps) => {
|
||||
@@ -31,6 +33,7 @@ const BlacklistBlock = ({
|
||||
const intl = useIntl();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const { addToast } = useToasts();
|
||||
const { data } = useSWR<Blacklist>(`/api/v1/blacklist/${tmdbId}`);
|
||||
|
||||
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
|
||||
setIsUpdating(true);
|
||||
@@ -62,6 +65,14 @@ const BlacklistBlock = ({
|
||||
setIsUpdating(false);
|
||||
};
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
<LoadingSpinner />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 text-gray-300">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -73,13 +84,13 @@ const BlacklistBlock = ({
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
<Link
|
||||
href={
|
||||
blacklistItem.user.id === user?.id
|
||||
data.user.id === user?.id
|
||||
? '/profile'
|
||||
: `/users/${blacklistItem.user.id}`
|
||||
: `/users/${data.user.id}`
|
||||
}
|
||||
>
|
||||
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
|
||||
{blacklistItem.user.displayName}
|
||||
{data.user.displayName}
|
||||
</span>
|
||||
</Link>
|
||||
</span>
|
||||
@@ -91,9 +102,7 @@ const BlacklistBlock = ({
|
||||
>
|
||||
<Button
|
||||
buttonType="danger"
|
||||
onClick={() =>
|
||||
removeFromBlacklist(blacklistItem.tmdbId, blacklistItem.title)
|
||||
}
|
||||
onClick={() => removeFromBlacklist(data.tmdbId, data.title)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<TrashIcon className="icon-sm" />
|
||||
@@ -114,7 +123,7 @@ const BlacklistBlock = ({
|
||||
<CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
|
||||
</Tooltip>
|
||||
<span>
|
||||
{intl.formatDate(blacklistItem.createdAt, {
|
||||
{intl.formatDate(data.createdAt, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
|
||||
@@ -38,7 +38,7 @@ const BlacklistModal = ({
|
||||
const intl = useIntl();
|
||||
|
||||
const { data, error } = useSWR<TvDetails | MovieDetails>(
|
||||
`/api/v1/${type}/${tmdbId}`
|
||||
show ? `/api/v1/${type}/${tmdbId}` : null
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -198,6 +198,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
{data.backdropPath && (
|
||||
<div className="media-page-bg-image">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
alt=""
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
@@ -228,6 +229,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
<div className="media-header">
|
||||
<div className="media-poster">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={
|
||||
data.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||
|
||||
@@ -4,24 +4,31 @@ import Image from 'next/image';
|
||||
|
||||
const imageLoader: ImageLoader = ({ src }) => src;
|
||||
|
||||
export type CachedImageProps = ImageProps & {
|
||||
src: string;
|
||||
type: 'tmdb' | 'avatar';
|
||||
};
|
||||
|
||||
/**
|
||||
* The CachedImage component should be used wherever
|
||||
* we want to offer the option to locally cache images.
|
||||
**/
|
||||
const CachedImage = ({ src, ...props }: ImageProps) => {
|
||||
const CachedImage = ({ src, type, ...props }: CachedImageProps) => {
|
||||
const { currentSettings } = useSettings();
|
||||
|
||||
let imageUrl = src;
|
||||
let imageUrl: string;
|
||||
|
||||
if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
|
||||
const parsedUrl = new URL(imageUrl);
|
||||
|
||||
if (parsedUrl.host === 'image.tmdb.org') {
|
||||
if (currentSettings.cacheImages)
|
||||
imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy');
|
||||
} else if (parsedUrl.host !== 'gravatar.com') {
|
||||
imageUrl = '/avatarproxy/' + imageUrl;
|
||||
}
|
||||
if (type === 'tmdb') {
|
||||
// tmdb stuff
|
||||
imageUrl =
|
||||
currentSettings.cacheImages && !src.startsWith('/')
|
||||
? src.replace(/^https:\/\/image\.tmdb\.org\//, '/imageproxy/')
|
||||
: src;
|
||||
} else if (type === 'avatar') {
|
||||
// jellyfin avatar (if any)
|
||||
imageUrl = src;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />;
|
||||
|
||||
@@ -61,6 +61,7 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
|
||||
{...props}
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
alt=""
|
||||
src={imageUrl}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useLockBodyScroll } from '@app/hooks/useLockBodyScroll';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import { Transition } from '@headlessui/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 { useIntl } from 'react-intl';
|
||||
|
||||
@@ -66,8 +66,12 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
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, () => {
|
||||
if (onCancel && backgroundClickable) {
|
||||
if (onCancel && backgroundClickableRef.current) {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
@@ -123,6 +127,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
{backdrop && (
|
||||
<div className="absolute top-0 left-0 right-0 z-0 h-64 max-h-full w-full">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
alt=""
|
||||
src={backdrop}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
|
||||
@@ -33,6 +33,7 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => {
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={image}
|
||||
alt={name}
|
||||
className="relative z-40 h-full w-full"
|
||||
|
||||
@@ -7,7 +7,9 @@ import defineMessages from '@app/utils/defineMessages';
|
||||
import type { TvResult } from '@server/models/Search';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.DiscoverTvUpcoming', {});
|
||||
const messages = defineMessages('components.DiscoverTvUpcoming', {
|
||||
upcomingtv: 'Upcoming Series',
|
||||
});
|
||||
|
||||
const DiscoverTvUpcoming = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -36,6 +36,7 @@ const GenreCard = ({ image, url, name, canExpand = false }: GenreCardProps) => {
|
||||
tabIndex={0}
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={image}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
|
||||
@@ -89,7 +89,8 @@ const IssueComment = ({
|
||||
</Transition>
|
||||
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
|
||||
<CachedImage
|
||||
src={`${comment.user.avatar}`}
|
||||
type="avatar"
|
||||
src={comment.user.avatar}
|
||||
alt=""
|
||||
className="h-10 w-10 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||
width={40}
|
||||
|
||||
@@ -217,6 +217,7 @@ const IssueDetails = () => {
|
||||
{data.backdropPath && (
|
||||
<div className="media-page-bg-image">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
alt=""
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
@@ -235,6 +236,7 @@ const IssueDetails = () => {
|
||||
<div className="media-header">
|
||||
<div className="media-poster">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={
|
||||
data.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||
@@ -287,7 +289,8 @@ const IssueDetails = () => {
|
||||
className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
|
||||
>
|
||||
<CachedImage
|
||||
src={`${issueData.createdBy.avatar}`}
|
||||
type="avatar"
|
||||
src={issueData.createdBy.avatar}
|
||||
alt=""
|
||||
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
|
||||
width={20}
|
||||
|
||||
@@ -112,6 +112,7 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
||||
{title.backdropPath && (
|
||||
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
@@ -137,6 +138,7 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
||||
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={
|
||||
title.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||
@@ -226,7 +228,8 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
||||
className="group flex items-center truncate"
|
||||
>
|
||||
<CachedImage
|
||||
src={'/avatarproxy/' + issue.createdBy.avatar}
|
||||
type="avatar"
|
||||
src={issue.createdBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm ml-1.5 object-cover"
|
||||
width={20}
|
||||
|
||||
@@ -57,6 +57,7 @@ const UserDropdown = () => {
|
||||
data-testid="user-menu"
|
||||
>
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
||||
src={user ? user.avatar : ''}
|
||||
alt=""
|
||||
@@ -80,6 +81,7 @@ const UserDropdown = () => {
|
||||
<div className="flex flex-col space-y-4 px-4 py-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
||||
src={user ? user.avatar : ''}
|
||||
alt=""
|
||||
|
||||
@@ -82,10 +82,17 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
port: Yup.number().required(
|
||||
intl.formatMessage(messages.validationPortRequired)
|
||||
),
|
||||
urlBase: Yup.string().matches(
|
||||
/^(.*[^/])$/,
|
||||
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
|
||||
),
|
||||
urlBase: Yup.string()
|
||||
.test(
|
||||
'leading-slash',
|
||||
intl.formatMessage(messages.validationUrlBaseLeadingSlash),
|
||||
(value) => !value || value.startsWith('/')
|
||||
)
|
||||
.test(
|
||||
'trailing-slash',
|
||||
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
|
||||
(value) => !value || !value.endsWith('/')
|
||||
),
|
||||
email: Yup.string()
|
||||
.email(intl.formatMessage(messages.validationemailformat))
|
||||
.required(intl.formatMessage(messages.validationemailrequired)),
|
||||
|
||||
@@ -292,7 +292,7 @@ const ManageSlideOver = ({
|
||||
</h3>
|
||||
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
||||
<BlacklistBlock
|
||||
blacklistItem={data.mediaInfo.blacklist}
|
||||
tmdbId={data.mediaInfo.tmdbId}
|
||||
onUpdate={() => revalidate()}
|
||||
onDelete={() => onClose()}
|
||||
/>
|
||||
@@ -369,6 +369,7 @@ const ManageSlideOver = ({
|
||||
content={user.displayName}
|
||||
>
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
src={user.avatar}
|
||||
alt={user.displayName}
|
||||
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||
@@ -530,6 +531,7 @@ const ManageSlideOver = ({
|
||||
content={user.displayName}
|
||||
>
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
src={user.avatar}
|
||||
alt={user.displayName}
|
||||
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||
|
||||
@@ -448,6 +448,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
{data.backdropPath && (
|
||||
<div className="media-page-bg-image">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
alt=""
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
@@ -494,6 +495,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
<div className="media-header">
|
||||
<div className="media-poster">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={
|
||||
data.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||
@@ -741,6 +743,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
<div className="group relative z-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-lg bg-gray-800 bg-cover bg-center shadow-md ring-1 ring-gray-700 transition duration-300 hover:scale-105 hover:ring-gray-500">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={`https://image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath}`}
|
||||
alt=""
|
||||
style={{
|
||||
|
||||
@@ -51,6 +51,7 @@ const PersonCard = ({
|
||||
{profilePath ? (
|
||||
<div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
|
||||
alt=""
|
||||
style={{
|
||||
|
||||
@@ -227,6 +227,7 @@ const PersonDetails = () => {
|
||||
{data.profilePath && (
|
||||
<div className="relative mb-6 mr-0 h-36 w-36 flex-shrink-0 overflow-hidden rounded-full ring-1 ring-gray-700 lg:mb-0 lg:mr-6 lg:h-44 lg:w-44">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath}`}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
|
||||
@@ -116,6 +116,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
||||
>
|
||||
<span className="avatar-sm">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
@@ -345,6 +346,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
{title.backdropPath && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
alt=""
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
@@ -390,6 +392,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
>
|
||||
<span className="avatar-sm">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
@@ -602,6 +605,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
className="w-20 flex-shrink-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-md shadow-sm transition duration-300 hover:scale-105 hover:shadow-md sm:w-28"
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={
|
||||
title.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||
|
||||
@@ -191,6 +191,7 @@ const RequestItemError = ({
|
||||
>
|
||||
<span className="avatar-sm ml-1.5">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
@@ -250,6 +251,7 @@ const RequestItemError = ({
|
||||
>
|
||||
<span className="avatar-sm ml-1.5">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
src={requestData.modifiedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
@@ -418,6 +420,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
{title.backdropPath && (
|
||||
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
@@ -443,6 +446,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={
|
||||
title.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||
@@ -570,6 +574,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
>
|
||||
<span className="avatar-sm ml-1.5">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
@@ -629,7 +634,8 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
>
|
||||
<span className="avatar-sm ml-1.5">
|
||||
<CachedImage
|
||||
src={requestData.requestedBy.avatar}
|
||||
type="avatar"
|
||||
src={requestData.modifiedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
width={20}
|
||||
|
||||
@@ -562,6 +562,7 @@ const AdvancedRequester = ({
|
||||
<Listbox.Button className="focus:shadow-outline-blue relative w-full cursor-default rounded-md border border-gray-700 bg-gray-800 py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5">
|
||||
<span className="flex items-center">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
src={selectedUser.avatar}
|
||||
alt=""
|
||||
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
|
||||
@@ -614,6 +615,7 @@ const AdvancedRequester = ({
|
||||
} flex items-center`}
|
||||
>
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
src={user.avatar}
|
||||
alt=""
|
||||
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
|
||||
|
||||
@@ -437,6 +437,7 @@ const CollectionRequestModal = ({
|
||||
>
|
||||
<div className="relative h-auto w-10 flex-shrink-0 overflow-hidden rounded-md">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={
|
||||
part.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${part.posterPath}`
|
||||
|
||||
@@ -88,14 +88,14 @@ const SearchByNameModal = ({
|
||||
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
|
||||
src={
|
||||
item.remotePoster ??
|
||||
'/images/overseerr_poster_not_found.png'
|
||||
}
|
||||
alt={item.title}
|
||||
className="h-100 w-auto rounded-md"
|
||||
className="w-100 h-auto rounded-md"
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
TmdbKeywordSearchResponse,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
|
||||
import type {
|
||||
Keyword,
|
||||
ProductionCompany,
|
||||
@@ -29,6 +30,7 @@ const messages = defineMessages('components.Selector', {
|
||||
searchKeywords: 'Search keywords…',
|
||||
searchGenres: 'Select genres…',
|
||||
searchStudios: 'Search studios…',
|
||||
searchUsers: 'Select users…',
|
||||
starttyping: 'Starting typing to search.',
|
||||
nooptions: 'No results.',
|
||||
showmore: 'Show More',
|
||||
@@ -437,7 +439,7 @@ export const WatchProviderSelector = ({
|
||||
key={`prodiver-${provider.id}`}
|
||||
>
|
||||
<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
|
||||
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
||||
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
||||
@@ -451,17 +453,15 @@ export const WatchProviderSelector = ({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<CachedImage
|
||||
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
||||
alt=""
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
fill
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<div className="relative m-2 aspect-1">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
||||
alt=""
|
||||
fill
|
||||
className="rounded-lg object-contain"
|
||||
/>
|
||||
</div>
|
||||
{isActive && (
|
||||
<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" />
|
||||
@@ -482,7 +482,7 @@ export const WatchProviderSelector = ({
|
||||
key={`prodiver-${provider.id}`}
|
||||
>
|
||||
<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
|
||||
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
||||
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
||||
@@ -496,17 +496,15 @@ export const WatchProviderSelector = ({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<CachedImage
|
||||
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
||||
alt=""
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
fill
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<div className="relative m-2 aspect-1">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
||||
alt=""
|
||||
fill
|
||||
className="rounded-lg object-contain"
|
||||
/>
|
||||
</div>
|
||||
{isActive && (
|
||||
<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" />
|
||||
@@ -546,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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,12 +19,16 @@ const messages = defineMessages('components.Settings.Notifications', {
|
||||
webhookUrl: 'Webhook URL',
|
||||
webhookUrlTip:
|
||||
'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!',
|
||||
discordsettingsfailed: 'Discord notification settings failed to save.',
|
||||
toastDiscordTestSending: 'Sending Discord test notification…',
|
||||
toastDiscordTestSuccess: 'Discord test notification sent!',
|
||||
toastDiscordTestFailed: 'Discord test notification failed to send.',
|
||||
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',
|
||||
enableMentions: 'Enable Mentions',
|
||||
});
|
||||
@@ -53,6 +57,12 @@ const NotificationsDiscord = () => {
|
||||
otherwise: Yup.string().nullable(),
|
||||
})
|
||||
.url(intl.formatMessage(messages.validationUrl)),
|
||||
webhookRoleId: Yup.string()
|
||||
.nullable()
|
||||
.matches(
|
||||
/^\d{17,19}$/,
|
||||
intl.formatMessage(messages.validationWebhookRoleId)
|
||||
),
|
||||
});
|
||||
|
||||
if (!data && !error) {
|
||||
@@ -67,6 +77,7 @@ const NotificationsDiscord = () => {
|
||||
botUsername: data?.options.botUsername,
|
||||
botAvatarUrl: data?.options.botAvatarUrl,
|
||||
webhookUrl: data.options.webhookUrl,
|
||||
webhookRoleId: data?.options.webhookRoleId,
|
||||
enableMentions: data?.options.enableMentions,
|
||||
}}
|
||||
validationSchema={NotificationsDiscordSchema}
|
||||
@@ -84,6 +95,7 @@ const NotificationsDiscord = () => {
|
||||
botUsername: values.botUsername,
|
||||
botAvatarUrl: values.botAvatarUrl,
|
||||
webhookUrl: values.webhookUrl,
|
||||
webhookRoleId: values.webhookRoleId,
|
||||
enableMentions: values.enableMentions,
|
||||
},
|
||||
}),
|
||||
@@ -141,6 +153,7 @@ const NotificationsDiscord = () => {
|
||||
botUsername: values.botUsername,
|
||||
botAvatarUrl: values.botAvatarUrl,
|
||||
webhookUrl: values.webhookUrl,
|
||||
webhookRoleId: values.webhookRoleId,
|
||||
enableMentions: values.enableMentions,
|
||||
},
|
||||
}),
|
||||
@@ -254,6 +267,21 @@ const NotificationsDiscord = () => {
|
||||
)}
|
||||
</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">
|
||||
<label htmlFor="enableMentions" className="checkbox-label">
|
||||
{intl.formatMessage(messages.enableMentions)}
|
||||
|
||||
391
src/components/Settings/OverrideRule/OverrideRuleModal.tsx
Normal file
391
src/components/Settings/OverrideRule/OverrideRuleModal.tsx
Normal 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;
|
||||
267
src/components/Settings/OverrideRule/OverrideRuleTile.tsx
Normal file
267
src/components/Settings/OverrideRule/OverrideRuleTile.tsx
Normal 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;
|
||||
@@ -1,14 +1,21 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
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 defineMessages from '@app/utils/defineMessages';
|
||||
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 { Field, Formik } from 'formik';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import Select from 'react-select';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
type OptionType = {
|
||||
@@ -69,41 +76,46 @@ const messages = defineMessages('components.Settings.RadarrModal', {
|
||||
announced: 'Announced',
|
||||
inCinemas: 'In Cinemas',
|
||||
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 {
|
||||
radarr: RadarrSettings | null;
|
||||
onClose: () => 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 { data: rules, mutate: revalidate } =
|
||||
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
|
||||
const initialLoad = useRef(false);
|
||||
const { addToast } = useToasts();
|
||||
const [isValidated, setIsValidated] = useState(radarr ? true : false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testResponse, setTestResponse] = useState<TestResponse>({
|
||||
const [testResponse, setTestResponse] = useState<DVRTestResponse>({
|
||||
profiles: [],
|
||||
rootFolders: [],
|
||||
tags: [],
|
||||
});
|
||||
|
||||
const RadarrSettingsSchema = Yup.object().shape({
|
||||
name: Yup.string().required(
|
||||
intl.formatMessage(messages.validationNameRequired)
|
||||
@@ -130,7 +142,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
|
||||
),
|
||||
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(
|
||||
'no-trailing-slash',
|
||||
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
||||
@@ -217,6 +232,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||
}
|
||||
}, [radarr, testConnection]);
|
||||
|
||||
useEffect(() => {
|
||||
revalidate();
|
||||
}, [overrideRuleModal, revalidate]);
|
||||
|
||||
return (
|
||||
<Transition
|
||||
as="div"
|
||||
@@ -360,6 +379,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||
values.is4k ? messages.edit4kradarr : messages.editradarr
|
||||
)
|
||||
}
|
||||
backgroundClickable={!overrideRuleModal.open}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<div className="form-row">
|
||||
@@ -750,6 +770,38 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||
</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>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -139,7 +139,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
),
|
||||
jellyfinExternalUrl: Yup.string()
|
||||
.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(
|
||||
'no-trailing-slash',
|
||||
intl.formatMessage(messages.validationUrlTrailingSlash),
|
||||
@@ -147,7 +150,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
),
|
||||
jellyfinForgotPasswordUrl: Yup.string()
|
||||
.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(
|
||||
'no-trailing-slash',
|
||||
intl.formatMessage(messages.validationUrlTrailingSlash),
|
||||
|
||||
@@ -55,6 +55,17 @@ const messages = defineMessages('components.Settings.SettingsMain', {
|
||||
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||
partialRequestsEnabled: 'Allow Partial Series Requests',
|
||||
locale: 'Display Language',
|
||||
proxyEnabled: 'HTTP(S) Proxy',
|
||||
proxyHostname: 'Proxy Hostname',
|
||||
proxyPort: 'Proxy Port',
|
||||
proxySsl: 'Use SSL For Proxy',
|
||||
proxyUser: 'Proxy Username',
|
||||
proxyPassword: 'Proxy Password',
|
||||
proxyBypassFilter: 'Proxy Ignored Addresses',
|
||||
proxyBypassFilterTip:
|
||||
"Use ',' as a separator, and '*.' as a wildcard for subdomains",
|
||||
proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses',
|
||||
validationProxyPort: 'You must provide a valid port',
|
||||
});
|
||||
|
||||
const SettingsMain = () => {
|
||||
@@ -76,12 +87,21 @@ const SettingsMain = () => {
|
||||
intl.formatMessage(messages.validationApplicationTitle)
|
||||
),
|
||||
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(
|
||||
'no-trailing-slash',
|
||||
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
||||
(value) => !value || !value.endsWith('/')
|
||||
),
|
||||
proxyPort: Yup.number().when('proxyEnabled', {
|
||||
is: (proxyEnabled: boolean) => proxyEnabled,
|
||||
then: Yup.number().required(
|
||||
intl.formatMessage(messages.validationProxyPort)
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
const regenerate = async () => {
|
||||
@@ -137,6 +157,14 @@ const SettingsMain = () => {
|
||||
partialRequestsEnabled: data?.partialRequestsEnabled,
|
||||
trustProxy: data?.trustProxy,
|
||||
cacheImages: data?.cacheImages,
|
||||
proxyEnabled: data?.proxy?.enabled,
|
||||
proxyHostname: data?.proxy?.hostname,
|
||||
proxyPort: data?.proxy?.port,
|
||||
proxySsl: data?.proxy?.useSsl,
|
||||
proxyUser: data?.proxy?.user,
|
||||
proxyPassword: data?.proxy?.password,
|
||||
proxyBypassFilter: data?.proxy?.bypassFilter,
|
||||
proxyBypassLocalAddresses: data?.proxy?.bypassLocalAddresses,
|
||||
}}
|
||||
enableReinitialize
|
||||
validationSchema={MainSettingsSchema}
|
||||
@@ -158,6 +186,16 @@ const SettingsMain = () => {
|
||||
partialRequestsEnabled: values.partialRequestsEnabled,
|
||||
trustProxy: values.trustProxy,
|
||||
cacheImages: values.cacheImages,
|
||||
proxy: {
|
||||
enabled: values.proxyEnabled,
|
||||
hostname: values.proxyHostname,
|
||||
port: values.proxyPort,
|
||||
useSsl: values.proxySsl,
|
||||
user: values.proxyUser,
|
||||
password: values.proxyPassword,
|
||||
bypassFilter: values.proxyBypassFilter,
|
||||
bypassLocalAddresses: values.proxyBypassLocalAddresses,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
@@ -437,6 +475,176 @@ const SettingsMain = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="proxyEnabled" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.proxyEnabled)}
|
||||
</span>
|
||||
<SettingsBadge badgeType="advanced" className="mr-2" />
|
||||
<SettingsBadge badgeType="restartRequired" />
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="proxyEnabled"
|
||||
name="proxyEnabled"
|
||||
onChange={() => {
|
||||
setFieldValue('proxyEnabled', !values.proxyEnabled);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{values.proxyEnabled && (
|
||||
<>
|
||||
<div className="form-row">
|
||||
<label htmlFor="proxyHostname" className="checkbox-label">
|
||||
<span className="mr-2 ml-4">
|
||||
{intl.formatMessage(messages.proxyHostname)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="proxyHostname"
|
||||
name="proxyHostname"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
{errors.proxyHostname &&
|
||||
touched.proxyHostname &&
|
||||
typeof errors.proxyHostname === 'string' && (
|
||||
<div className="error">{errors.proxyHostname}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="proxyPort" className="checkbox-label">
|
||||
<span className="mr-2 ml-4">
|
||||
{intl.formatMessage(messages.proxyPort)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field id="proxyPort" name="proxyPort" type="text" />
|
||||
</div>
|
||||
{errors.proxyPort &&
|
||||
touched.proxyPort &&
|
||||
typeof errors.proxyPort === 'string' && (
|
||||
<div className="error">{errors.proxyPort}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="proxySsl" className="checkbox-label">
|
||||
<span className="mr-2 ml-4">
|
||||
{intl.formatMessage(messages.proxySsl)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="proxySsl"
|
||||
name="proxySsl"
|
||||
onChange={() => {
|
||||
setFieldValue('proxySsl', !values.proxySsl);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="proxyUser" className="checkbox-label">
|
||||
<span className="mr-2 ml-4">
|
||||
{intl.formatMessage(messages.proxyUser)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field id="proxyUser" name="proxyUser" type="text" />
|
||||
</div>
|
||||
{errors.proxyUser &&
|
||||
touched.proxyUser &&
|
||||
typeof errors.proxyUser === 'string' && (
|
||||
<div className="error">{errors.proxyUser}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="proxyPassword" className="checkbox-label">
|
||||
<span className="mr-2 ml-4">
|
||||
{intl.formatMessage(messages.proxyPassword)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="proxyPassword"
|
||||
name="proxyPassword"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
{errors.proxyPassword &&
|
||||
touched.proxyPassword &&
|
||||
typeof errors.proxyPassword === 'string' && (
|
||||
<div className="error">{errors.proxyPassword}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="proxyBypassFilter"
|
||||
className="checkbox-label"
|
||||
>
|
||||
<span className="mr-2 ml-4">
|
||||
{intl.formatMessage(messages.proxyBypassFilter)}
|
||||
</span>
|
||||
<span className="label-tip ml-4">
|
||||
{intl.formatMessage(messages.proxyBypassFilterTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="proxyBypassFilter"
|
||||
name="proxyBypassFilter"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
{errors.proxyBypassFilter &&
|
||||
touched.proxyBypassFilter &&
|
||||
typeof errors.proxyBypassFilter === 'string' && (
|
||||
<div className="error">
|
||||
{errors.proxyBypassFilter}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="proxyBypassLocalAddresses"
|
||||
className="checkbox-label"
|
||||
>
|
||||
<span className="mr-2 ml-4">
|
||||
{intl.formatMessage(
|
||||
messages.proxyBypassLocalAddresses
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="proxyBypassLocalAddresses"
|
||||
name="proxyBypassLocalAddresses"
|
||||
onChange={() => {
|
||||
setFieldValue(
|
||||
'proxyBypassLocalAddresses',
|
||||
!values.proxyBypassLocalAddresses
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
|
||||
@@ -190,7 +190,10 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
||||
otherwise: Yup.string().nullable(),
|
||||
}),
|
||||
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(
|
||||
'no-trailing-slash',
|
||||
intl.formatMessage(messages.validationUrlTrailingSlash),
|
||||
|
||||
@@ -6,12 +6,14 @@ import Button from '@app/components/Common/Button';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import OverrideRuleModal from '@app/components/Settings/OverrideRule/OverrideRuleModal';
|
||||
import RadarrModal from '@app/components/Settings/RadarrModal';
|
||||
import SonarrModal from '@app/components/Settings/SonarrModal';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
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 { Fragment, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
@@ -57,6 +59,22 @@ interface ServerInstanceProps {
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export interface DVRTestResponse {
|
||||
profiles: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
rootFolders: {
|
||||
id: number;
|
||||
path: string;
|
||||
}[];
|
||||
tags: {
|
||||
id: number;
|
||||
label: string;
|
||||
}[];
|
||||
urlBase?: string;
|
||||
}
|
||||
|
||||
const ServerInstance = ({
|
||||
name,
|
||||
hostname,
|
||||
@@ -193,6 +211,15 @@ const SettingsServices = () => {
|
||||
type: 'radarr',
|
||||
serverId: null,
|
||||
});
|
||||
const [overrideRuleModal, setOverrideRuleModal] = useState<{
|
||||
open: boolean;
|
||||
rule: OverrideRule | null;
|
||||
testResponse: DVRTestResponse | null;
|
||||
}>({
|
||||
open: false,
|
||||
rule: null,
|
||||
testResponse: null,
|
||||
});
|
||||
|
||||
const deleteServer = async () => {
|
||||
const res = await fetch(
|
||||
@@ -227,26 +254,51 @@ const SettingsServices = () => {
|
||||
})}
|
||||
</p>
|
||||
</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 && (
|
||||
<RadarrModal
|
||||
radarr={editRadarrModal.radarr}
|
||||
onClose={() => setEditRadarrModal({ open: false, radarr: null })}
|
||||
onClose={() => {
|
||||
if (!overrideRuleModal.open)
|
||||
setEditRadarrModal({ open: false, radarr: null });
|
||||
}}
|
||||
onSave={() => {
|
||||
revalidateRadarr();
|
||||
mutate('/api/v1/settings/public');
|
||||
setEditRadarrModal({ open: false, radarr: null });
|
||||
}}
|
||||
overrideRuleModal={overrideRuleModal}
|
||||
setOverrideRuleModal={setOverrideRuleModal}
|
||||
/>
|
||||
)}
|
||||
{editSonarrModal.open && (
|
||||
<SonarrModal
|
||||
sonarr={editSonarrModal.sonarr}
|
||||
onClose={() => setEditSonarrModal({ open: false, sonarr: null })}
|
||||
onClose={() => {
|
||||
if (!overrideRuleModal.open)
|
||||
setEditSonarrModal({ open: false, sonarr: null });
|
||||
}}
|
||||
onSave={() => {
|
||||
revalidateSonarr();
|
||||
mutate('/api/v1/settings/public');
|
||||
setEditSonarrModal({ open: false, sonarr: null });
|
||||
}}
|
||||
overrideRuleModal={overrideRuleModal}
|
||||
setOverrideRuleModal={setOverrideRuleModal}
|
||||
/>
|
||||
)}
|
||||
<Transition
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
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 defineMessages from '@app/utils/defineMessages';
|
||||
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 { Field, Formik } from 'formik';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
@@ -10,6 +16,7 @@ import { useIntl } from 'react-intl';
|
||||
import type { OnChangeValue } from 'react-select';
|
||||
import Select from 'react-select';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
type OptionType = {
|
||||
@@ -75,46 +82,56 @@ const messages = defineMessages('components.Settings.SonarrModal', {
|
||||
animeTags: 'Anime Tags',
|
||||
notagoptions: 'No tags.',
|
||||
selecttags: 'Select tags',
|
||||
overrideRules: 'Override Rules',
|
||||
addrule: 'New Override Rule',
|
||||
});
|
||||
|
||||
interface TestResponse {
|
||||
profiles: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
rootFolders: {
|
||||
id: number;
|
||||
path: string;
|
||||
}[];
|
||||
languageProfiles: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
tags: {
|
||||
id: number;
|
||||
label: string;
|
||||
}[];
|
||||
urlBase?: string;
|
||||
interface SonarrTestResponse extends DVRTestResponse {
|
||||
languageProfiles:
|
||||
| {
|
||||
id: number;
|
||||
name: string;
|
||||
}[]
|
||||
| null;
|
||||
}
|
||||
|
||||
interface SonarrModalProps {
|
||||
sonarr: SonarrSettings | null;
|
||||
onClose: () => 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 { data: rules, mutate: revalidate } =
|
||||
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
|
||||
const initialLoad = useRef(false);
|
||||
const { addToast } = useToasts();
|
||||
const [isValidated, setIsValidated] = useState(sonarr ? true : false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testResponse, setTestResponse] = useState<TestResponse>({
|
||||
const [testResponse, setTestResponse] = useState<SonarrTestResponse>({
|
||||
profiles: [],
|
||||
rootFolders: [],
|
||||
languageProfiles: [],
|
||||
languageProfiles: null,
|
||||
tags: [],
|
||||
});
|
||||
|
||||
const SonarrSettingsSchema = Yup.object().shape({
|
||||
name: Yup.string().required(
|
||||
intl.formatMessage(messages.validationNameRequired)
|
||||
@@ -137,11 +154,16 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
activeProfileId: Yup.string().required(
|
||||
intl.formatMessage(messages.validationProfileRequired)
|
||||
),
|
||||
activeLanguageProfileId: Yup.number().required(
|
||||
intl.formatMessage(messages.validationLanguageProfileRequired)
|
||||
),
|
||||
activeLanguageProfileId: testResponse.languageProfiles
|
||||
? Yup.number().required(
|
||||
intl.formatMessage(messages.validationLanguageProfileRequired)
|
||||
)
|
||||
: Yup.number(),
|
||||
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(
|
||||
'no-trailing-slash',
|
||||
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
||||
@@ -190,7 +212,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const data: TestResponse = await res.json();
|
||||
const data: SonarrTestResponse = await res.json();
|
||||
|
||||
setIsValidated(true);
|
||||
setTestResponse(data);
|
||||
@@ -228,6 +250,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
}
|
||||
}, [sonarr, testConnection]);
|
||||
|
||||
useEffect(() => {
|
||||
revalidate();
|
||||
}, [overrideRuleModal, revalidate]);
|
||||
|
||||
return (
|
||||
<Transition
|
||||
as="div"
|
||||
@@ -395,6 +421,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
values.is4k ? messages.edit4ksonarr : messages.editsonarr
|
||||
)
|
||||
}
|
||||
backgroundClickable={!overrideRuleModal.open}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<div className="form-row">
|
||||
@@ -658,54 +685,56 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="activeLanguageProfileId"
|
||||
className="text-label"
|
||||
>
|
||||
{intl.formatMessage(messages.languageprofile)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="activeLanguageProfileId"
|
||||
name="activeLanguageProfileId"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
? intl.formatMessage(
|
||||
messages.loadinglanguageprofiles
|
||||
)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstLanguageProfiles
|
||||
)
|
||||
: intl.formatMessage(
|
||||
messages.selectLanguageProfile
|
||||
)}
|
||||
</option>
|
||||
{testResponse.languageProfiles.length > 0 &&
|
||||
testResponse.languageProfiles.map((language) => (
|
||||
<option
|
||||
key={`loaded-profile-${language.id}`}
|
||||
value={language.id}
|
||||
>
|
||||
{language.name}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
{testResponse.languageProfiles && (
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="activeLanguageProfileId"
|
||||
className="text-label"
|
||||
>
|
||||
{intl.formatMessage(messages.languageprofile)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="activeLanguageProfileId"
|
||||
name="activeLanguageProfileId"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
? intl.formatMessage(
|
||||
messages.loadinglanguageprofiles
|
||||
)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstLanguageProfiles
|
||||
)
|
||||
: intl.formatMessage(
|
||||
messages.selectLanguageProfile
|
||||
)}
|
||||
</option>
|
||||
{testResponse.languageProfiles.length > 0 &&
|
||||
testResponse.languageProfiles.map((language) => (
|
||||
<option
|
||||
key={`loaded-profile-${language.id}`}
|
||||
value={language.id}
|
||||
>
|
||||
{language.name}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
{errors.activeLanguageProfileId &&
|
||||
touched.activeLanguageProfileId && (
|
||||
<div className="error">
|
||||
{errors.activeLanguageProfileId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errors.activeLanguageProfileId &&
|
||||
touched.activeLanguageProfileId && (
|
||||
<div className="error">
|
||||
{errors.activeLanguageProfileId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-row">
|
||||
<label htmlFor="tags" className="text-label">
|
||||
{intl.formatMessage(messages.tags)}
|
||||
@@ -863,53 +892,55 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="activeAnimeLanguageProfileId"
|
||||
className="text-label"
|
||||
>
|
||||
{intl.formatMessage(messages.animelanguageprofile)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="activeAnimeLanguageProfileId"
|
||||
name="activeAnimeLanguageProfileId"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
? intl.formatMessage(
|
||||
messages.loadinglanguageprofiles
|
||||
)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstLanguageProfiles
|
||||
)
|
||||
: intl.formatMessage(
|
||||
messages.selectLanguageProfile
|
||||
)}
|
||||
</option>
|
||||
{testResponse.languageProfiles.length > 0 &&
|
||||
testResponse.languageProfiles.map((language) => (
|
||||
<option
|
||||
key={`loaded-profile-${language.id}`}
|
||||
value={language.id}
|
||||
>
|
||||
{language.name}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
{testResponse.languageProfiles && (
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="activeAnimeLanguageProfileId"
|
||||
className="text-label"
|
||||
>
|
||||
{intl.formatMessage(messages.animelanguageprofile)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="activeAnimeLanguageProfileId"
|
||||
name="activeAnimeLanguageProfileId"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
? intl.formatMessage(
|
||||
messages.loadinglanguageprofiles
|
||||
)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstLanguageProfiles
|
||||
)
|
||||
: intl.formatMessage(
|
||||
messages.selectLanguageProfile
|
||||
)}
|
||||
</option>
|
||||
{testResponse.languageProfiles.length > 0 &&
|
||||
testResponse.languageProfiles.map((language) => (
|
||||
<option
|
||||
key={`loaded-profile-${language.id}`}
|
||||
value={language.id}
|
||||
>
|
||||
{language.name}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
{errors.activeAnimeLanguageProfileId &&
|
||||
touched.activeAnimeLanguageProfileId && (
|
||||
<div className="error">
|
||||
{errors.activeAnimeLanguageProfileId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errors.activeAnimeLanguageProfileId &&
|
||||
touched.activeAnimeLanguageProfileId && (
|
||||
<div className="error">
|
||||
{errors.activeAnimeLanguageProfileId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-row">
|
||||
<label htmlFor="tags" className="text-label">
|
||||
{intl.formatMessage(messages.animeTags)}
|
||||
@@ -1045,6 +1076,38 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
</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>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -346,6 +346,7 @@ const TitleCard = ({
|
||||
>
|
||||
<div className="absolute inset-0 h-full w-full overflow-hidden">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
alt=""
|
||||
src={
|
||||
|
||||
@@ -471,6 +471,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
{data.backdropPath && (
|
||||
<div className="media-page-bg-image">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
alt=""
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
@@ -527,6 +528,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
<div className="media-header">
|
||||
<div className="media-poster">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={
|
||||
data.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||
|
||||
@@ -250,6 +250,7 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
||||
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
|
||||
<div className="flex items-center">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
className="h-10 w-10 flex-shrink-0 rounded-full"
|
||||
src={user.thumb}
|
||||
alt=""
|
||||
|
||||
@@ -634,6 +634,7 @@ const UserList = () => {
|
||||
className="h-10 w-10 flex-shrink-0"
|
||||
>
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
src={user.avatar}
|
||||
alt=""
|
||||
|
||||
@@ -43,6 +43,7 @@ const ProfileHeader = ({ user, isSettingsPage }: ProfileHeaderProps) => {
|
||||
<div className="flex-shrink-0">
|
||||
<div className="relative">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
className="h-24 w-24 rounded-full bg-gray-600 object-cover ring-1 ring-gray-700"
|
||||
src={user.avatar}
|
||||
alt=""
|
||||
|
||||
@@ -44,6 +44,8 @@ const messages = defineMessages(
|
||||
toastSettingsSuccess: 'Settings saved successfully!',
|
||||
toastSettingsFailure: 'Something went wrong while saving settings.',
|
||||
toastSettingsFailureEmail: 'This email is already taken!',
|
||||
toastSettingsFailureEmailEmpty:
|
||||
'Another user already has this username. You must set an email',
|
||||
region: 'Discover Region',
|
||||
regionTip: 'Filter content by regional availability',
|
||||
originallanguage: 'Discover Language',
|
||||
@@ -138,7 +140,7 @@ const UserGeneralSettings = () => {
|
||||
</div>
|
||||
<Formik
|
||||
initialValues={{
|
||||
displayName: data?.username ?? '',
|
||||
displayName: data?.username !== user?.email ? data?.username : '',
|
||||
email: data?.email?.includes('@') ? data.email : '',
|
||||
discordId: data?.discordId ?? '',
|
||||
locale: data?.locale,
|
||||
@@ -203,10 +205,23 @@ const UserGeneralSettings = () => {
|
||||
/* empty */
|
||||
}
|
||||
if (errorData?.message === ApiErrorCode.InvalidEmail) {
|
||||
addToast(intl.formatMessage(messages.toastSettingsFailureEmail), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
if (values.email) {
|
||||
addToast(
|
||||
intl.formatMessage(messages.toastSettingsFailureEmail),
|
||||
{
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
}
|
||||
);
|
||||
} else {
|
||||
addToast(
|
||||
intl.formatMessage(messages.toastSettingsFailureEmailEmpty),
|
||||
{
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
addToast(intl.formatMessage(messages.toastSettingsFailure), {
|
||||
autoDismiss: true,
|
||||
@@ -284,9 +299,9 @@ const UserGeneralSettings = () => {
|
||||
name="displayName"
|
||||
type="text"
|
||||
placeholder={
|
||||
user?.username ||
|
||||
user?.jellyfinUsername ||
|
||||
user?.plexUsername
|
||||
user?.plexUsername ||
|
||||
user?.email
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -25,15 +25,14 @@ async function extractMessages(
|
||||
try {
|
||||
const formattedMessages = messages
|
||||
.trim()
|
||||
.replace(/^\s*(['"])?([a-zA-Z0-9_-]+)(['"])?:/gm, '"$2":')
|
||||
.replace(
|
||||
/'.*'/g,
|
||||
(match) =>
|
||||
`"${match
|
||||
.match(/'(.*)'/)?.[1]
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')}"`
|
||||
)
|
||||
.replace(/^\s*(['"])?([a-zA-Z0-9_-]+)(['"])?:[\s\n]*/gm, '"$2":')
|
||||
.replace(/^"[a-zA-Z0-9_-]+":'.*',?$/gm, (match) => {
|
||||
const parts = /^("[a-zA-Z0-9_-]+":)'(.*)',?$/.exec(match);
|
||||
if (!parts) return match;
|
||||
return `${parts[1]}"${parts[2]
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')}",`;
|
||||
})
|
||||
.replace(/,$/, '');
|
||||
const messagesJson = JSON.parse(`{${formattedMessages}}`);
|
||||
return { namespace: namespace.trim(), messages: messagesJson };
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"components.Discover.StudioSlider.studios": "Studios",
|
||||
"components.Discover.TvGenreList.seriesgenres": "Series Genres",
|
||||
"components.Discover.TvGenreSlider.tvgenres": "Series Genres",
|
||||
"components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series",
|
||||
"components.Discover.createnewslider": "Create New Slider",
|
||||
"components.Discover.customizediscover": "Customize Discover",
|
||||
"components.Discover.discover": "Discover",
|
||||
@@ -298,7 +299,6 @@
|
||||
"components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {play} other {plays}}",
|
||||
"components.ManageSlideOver.removearr": "Remove from {arr}",
|
||||
"components.ManageSlideOver.removearr4k": "Remove from 4K {arr}",
|
||||
"components.RequestList.RequestItem.removearr": "Remove from {arr}",
|
||||
"components.ManageSlideOver.tvshow": "series",
|
||||
"components.MediaSlider.ShowMoreCard.seemore": "See More",
|
||||
"components.MovieDetails.MovieCast.fullcast": "Full Cast",
|
||||
@@ -494,6 +494,7 @@
|
||||
"components.RequestList.RequestItem.modified": "Modified",
|
||||
"components.RequestList.RequestItem.modifieduserdate": "{date} by {user}",
|
||||
"components.RequestList.RequestItem.profileName": "Profile",
|
||||
"components.RequestList.RequestItem.removearr": "Remove from {arr}",
|
||||
"components.RequestList.RequestItem.requested": "Requested",
|
||||
"components.RequestList.RequestItem.requesteddate": "Requested",
|
||||
"components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
|
||||
@@ -589,6 +590,7 @@
|
||||
"components.Selector.searchKeywords": "Search keywords…",
|
||||
"components.Selector.searchStatus": "Select status...",
|
||||
"components.Selector.searchStudios": "Search studios…",
|
||||
"components.Selector.searchUsers": "Select users…",
|
||||
"components.Selector.showless": "Show Less",
|
||||
"components.Selector.showmore": "Show More",
|
||||
"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.validationTypes": "You must select at least one notification type",
|
||||
"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.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.addrule": "New Override Rule",
|
||||
"components.Settings.RadarrModal.announced": "Announced",
|
||||
"components.Settings.RadarrModal.apiKey": "API Key",
|
||||
"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.createradarr": "Add New Radarr Server",
|
||||
"components.Settings.RadarrModal.createrule": "New Override Rule",
|
||||
"components.Settings.RadarrModal.default4kserver": "Default 4K Server",
|
||||
"components.Settings.RadarrModal.defaultserver": "Default Server",
|
||||
"components.Settings.RadarrModal.edit4kradarr": "Edit 4K 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.externalUrl": "External URL",
|
||||
"components.Settings.RadarrModal.genres": "Genres",
|
||||
"components.Settings.RadarrModal.hostname": "Hostname or IP Address",
|
||||
"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.loadingprofiles": "Loading quality profiles…",
|
||||
"components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…",
|
||||
"components.Settings.RadarrModal.minimumAvailability": "Minimum Availability",
|
||||
"components.Settings.RadarrModal.notagoptions": "No tags.",
|
||||
"components.Settings.RadarrModal.overrideRules": "Override Rules",
|
||||
"components.Settings.RadarrModal.port": "Port",
|
||||
"components.Settings.RadarrModal.qualityprofile": "Quality Profile",
|
||||
"components.Settings.RadarrModal.released": "Released",
|
||||
"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.selectQualityProfile": "Select quality profile",
|
||||
"components.Settings.RadarrModal.selectRootFolder": "Select root folder",
|
||||
"components.Settings.RadarrModal.selecttags": "Select tags",
|
||||
"components.Settings.RadarrModal.server4k": "4K Server",
|
||||
"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.syncEnabled": "Enable Scan",
|
||||
"components.Settings.RadarrModal.tagRequests": "Tag Requests",
|
||||
@@ -768,6 +796,7 @@
|
||||
"components.Settings.RadarrModal.testFirstTags": "Test connection to load tags",
|
||||
"components.Settings.RadarrModal.toastRadarrTestFailure": "Failed to connect to Radarr.",
|
||||
"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.validationApplicationUrl": "You must provide a valid URL",
|
||||
"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.originallanguageTip": "Filter content by original language",
|
||||
"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.regionTip": "Filter content by regional availability",
|
||||
"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.validationApplicationUrl": "You must provide a valid URL",
|
||||
"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.defaultPermissionsTip": "Initial permissions assigned to new users",
|
||||
"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.users": "Users",
|
||||
"components.Settings.SonarrModal.add": "Add Server",
|
||||
"components.Settings.SonarrModal.addrule": "New Override Rule",
|
||||
"components.Settings.SonarrModal.animeSeriesType": "Anime Series Type",
|
||||
"components.Settings.SonarrModal.animeTags": "Anime Tags",
|
||||
"components.Settings.SonarrModal.animelanguageprofile": "Anime Language Profile",
|
||||
@@ -932,6 +972,7 @@
|
||||
"components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…",
|
||||
"components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…",
|
||||
"components.Settings.SonarrModal.notagoptions": "No tags.",
|
||||
"components.Settings.SonarrModal.overrideRules": "Override Rules",
|
||||
"components.Settings.SonarrModal.port": "Port",
|
||||
"components.Settings.SonarrModal.qualityprofile": "Quality Profile",
|
||||
"components.Settings.SonarrModal.rootfolder": "Root Folder",
|
||||
@@ -1084,7 +1125,7 @@
|
||||
"components.Setup.finishing": "Finishing…",
|
||||
"components.Setup.servertype": "Choose Server Type",
|
||||
"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.signinWithEmby": "Enter your Emby details",
|
||||
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
|
||||
@@ -1235,6 +1276,7 @@
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.seriesrequestlimit": "Series Request Limit",
|
||||
"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.toastSettingsFailureEmailEmpty": "Another user already has this username. You must set an email",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.user": "User",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SettingsProvider } from '@app/context/SettingsContext';
|
||||
import { UserContext } from '@app/context/UserContext';
|
||||
import type { User } from '@app/hooks/useUser';
|
||||
import '@app/styles/globals.css';
|
||||
import '@app/utils/fetchOverride';
|
||||
import { polyfillIntl } from '@app/utils/polyfillIntl';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces';
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
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,
|
||||
messages: Record<string, string>
|
||||
) {
|
||||
const modifiedMessages: Record<
|
||||
string,
|
||||
{ id: string; defaultMessage: string }
|
||||
> = {};
|
||||
for (const key of Object.keys(messages)) {
|
||||
modifiedMessages[key] = {
|
||||
id: prefix + '.' + key,
|
||||
messages: T
|
||||
): Messages<T> {
|
||||
const keys: (keyof T)[] = Object.keys(messages);
|
||||
const modifiedMessagesEntries = keys.map((key) => [
|
||||
key,
|
||||
{
|
||||
id: `${prefix}.${key as string}`,
|
||||
defaultMessage: messages[key],
|
||||
};
|
||||
}
|
||||
},
|
||||
]);
|
||||
const modifiedMessages: Messages<T> = Object.fromEntries(
|
||||
modifiedMessagesEntries
|
||||
);
|
||||
return intlDefineMessages(modifiedMessages);
|
||||
}
|
||||
|
||||
46
src/utils/fetchOverride.ts
Normal file
46
src/utils/fetchOverride.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
const getCsrfToken = (): string | null => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isSameOrigin = (url: RequestInfo | URL): boolean => {
|
||||
const parsedUrl = new URL(
|
||||
url instanceof Request ? url.url : url.toString(),
|
||||
window.location.origin
|
||||
);
|
||||
return parsedUrl.origin === window.location.origin;
|
||||
};
|
||||
|
||||
// We are using a custom fetch implementation to add the X-XSRF-TOKEN heade
|
||||
// to all requests. This is required when CSRF protection is enabled.
|
||||
if (typeof window !== 'undefined') {
|
||||
const originalFetch: typeof fetch = window.fetch;
|
||||
|
||||
(window as typeof globalThis).fetch = async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
if (!isSameOrigin(input)) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const headers = {
|
||||
...(init?.headers || {}),
|
||||
...(csrfToken ? { 'XSRF-TOKEN': csrfToken } : {}),
|
||||
};
|
||||
|
||||
const newInit: RequestInit = {
|
||||
...init,
|
||||
headers,
|
||||
};
|
||||
|
||||
return originalFetch(input, newInit);
|
||||
};
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user