mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
Compare commits
26 Commits
preview-ca
...
preview-te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c26f576786 | ||
|
|
dd4b6fd859 | ||
|
|
64f4610b9f | ||
|
|
2d3b777daf | ||
|
|
cf59102ef9 | ||
|
|
ca838a00fa | ||
|
|
f2ed101e52 | ||
|
|
4b4eeb6ec7 | ||
|
|
d331798b28 | ||
|
|
f2b63156d1 | ||
|
|
326001c3ec | ||
|
|
0bbcfcbd5e | ||
|
|
32e0b129fe | ||
|
|
a2b3408c9a | ||
|
|
cbb1a74526 | ||
|
|
26c37ec067 | ||
|
|
4e48fdf2cb | ||
|
|
a351264b87 | ||
|
|
9de304d17a | ||
|
|
4945b54298 | ||
|
|
a0f80fe764 | ||
|
|
92ba26207d | ||
|
|
96e1d40304 | ||
|
|
a5d22ba5b8 | ||
|
|
f390da4866 | ||
|
|
edfd80444c |
@@ -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*
|
||||
|
||||
@@ -52,7 +52,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
|
||||
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
|
||||
|
||||
5. Create your patch and test your changes.
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -190,7 +190,7 @@ Caddy will automatically obtain and renew SSL certificates for your domain.
|
||||
|
||||
## Traefik (v2)
|
||||
|
||||
Add the following labels to the Jellyseerr service in your `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.
|
||||
|
||||
@@ -1988,6 +1988,9 @@ paths:
|
||||
appDataPath:
|
||||
type: string
|
||||
example: /app/config
|
||||
appDataPermissions:
|
||||
type: boolean
|
||||
example: true
|
||||
/settings/main:
|
||||
get:
|
||||
summary: Get main settings
|
||||
@@ -4139,6 +4142,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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -76,7 +90,7 @@ class ExternalAPI {
|
||||
}
|
||||
const data = await this.getDataFromResponse(response);
|
||||
|
||||
if (this.cache) {
|
||||
if (this.cache && ttl !== 0) {
|
||||
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
||||
}
|
||||
|
||||
@@ -120,7 +134,7 @@ class ExternalAPI {
|
||||
}
|
||||
const resData = await this.getDataFromResponse(response);
|
||||
|
||||
if (this.cache) {
|
||||
if (this.cache && ttl !== 0) {
|
||||
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
||||
}
|
||||
|
||||
@@ -164,7 +178,7 @@ class ExternalAPI {
|
||||
}
|
||||
const resData = await this.getDataFromResponse(response);
|
||||
|
||||
if (this.cache) {
|
||||
if (this.cache && ttl !== 0) {
|
||||
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
||||
}
|
||||
|
||||
|
||||
@@ -410,7 +410,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
).AccessToken;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while creating an API key the Jellyfin server: ${e.message}`,
|
||||
`Something went wrong while creating an API key from the Jellyfin server: ${e.message}`,
|
||||
{ label: 'Jellyfin API' }
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -157,9 +157,13 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
|
||||
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
||||
try {
|
||||
const data = await this.get<QueueResponse<QueueItemAppendT>>(`/queue`, {
|
||||
includeEpisode: 'true',
|
||||
});
|
||||
const data = await this.get<QueueResponse<QueueItemAppendT>>(
|
||||
`/queue`,
|
||||
{
|
||||
includeEpisode: 'true',
|
||||
},
|
||||
0
|
||||
);
|
||||
|
||||
return data.records;
|
||||
} catch (e) {
|
||||
@@ -193,15 +197,24 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
}
|
||||
};
|
||||
|
||||
async refreshMonitoredDownloads(): Promise<void> {
|
||||
await this.runCommand('RefreshMonitoredDownloads', {});
|
||||
}
|
||||
|
||||
protected async runCommand(
|
||||
commandName: string,
|
||||
options: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.post(`/command`, {
|
||||
name: commandName,
|
||||
...options,
|
||||
});
|
||||
await this.post(
|
||||
`/command`,
|
||||
{
|
||||
name: commandName,
|
||||
...options,
|
||||
},
|
||||
{},
|
||||
0
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
@@ -175,7 +188,7 @@ app
|
||||
},
|
||||
store: new TypeormStore({
|
||||
cleanupLimit: 2,
|
||||
ttl: 1000 * 60 * 60 * 24 * 30,
|
||||
ttl: 60 * 60 * 24 * 30,
|
||||
}).connect(sessionRespository) as Store,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -85,6 +85,7 @@ class DownloadTracker {
|
||||
});
|
||||
|
||||
try {
|
||||
await radarr.refreshMonitoredDownloads();
|
||||
const queueItems = await radarr.getQueue();
|
||||
|
||||
this.radarrServers[server.id] = queueItems.map((item) => ({
|
||||
@@ -162,6 +163,7 @@ class DownloadTracker {
|
||||
});
|
||||
|
||||
try {
|
||||
await sonarr.refreshMonitoredDownloads();
|
||||
const queueItems = await sonarr.getQueue();
|
||||
|
||||
this.sonarrServers[server.id] = queueItems.map((item) => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -99,6 +99,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 +130,7 @@ export interface MainSettings {
|
||||
mediaServerType: number;
|
||||
partialRequestsEnabled: boolean;
|
||||
locale: string;
|
||||
proxy: ProxySettings;
|
||||
}
|
||||
|
||||
interface PublicSettings {
|
||||
@@ -325,6 +337,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: '',
|
||||
@@ -479,10 +501,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 +602,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 +627,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 +641,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;
|
||||
|
||||
@@ -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,13 @@ 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();
|
||||
@@ -71,6 +70,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 +159,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,
|
||||
|
||||
@@ -17,7 +17,11 @@ import { mapProductionCompany } from '@server/models/Movie';
|
||||
import { mapNetwork } from '@server/models/Tv';
|
||||
import settingsRoutes from '@server/routes/settings';
|
||||
import watchlistRoutes from '@server/routes/watchlist';
|
||||
import { appDataPath, 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 +97,7 @@ router.get('/status/appdata', (_req, res) => {
|
||||
return res.status(200).json({
|
||||
appData: appDataStatus(),
|
||||
appDataPath: appDataPath(),
|
||||
appDataPermissions: appDataPermissions(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -516,12 +516,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 +539,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,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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -123,6 +123,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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CogIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
ExclamationTriangleIcon,
|
||||
EyeSlashIcon,
|
||||
FilmIcon,
|
||||
SparklesIcon,
|
||||
TvIcon,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
ClockIcon as FilledClockIcon,
|
||||
CogIcon as FilledCogIcon,
|
||||
ExclamationTriangleIcon as FilledExclamationTriangleIcon,
|
||||
EyeSlashIcon as FilledEyeSlashIcon,
|
||||
FilmIcon as FilledFilmIcon,
|
||||
SparklesIcon as FilledSparklesIcon,
|
||||
TvIcon as FilledTvIcon,
|
||||
@@ -84,6 +86,18 @@ const MobileMenu = () => {
|
||||
svgIconSelected: <FilledClockIcon className="h-6 w-6" />,
|
||||
activeRegExp: /^\/requests/,
|
||||
},
|
||||
{
|
||||
href: '/blacklist',
|
||||
content: intl.formatMessage(menuMessages.blacklist),
|
||||
svgIcon: <EyeSlashIcon className="h-6 w-6" />,
|
||||
svgIconSelected: <FilledEyeSlashIcon className="h-6 w-6" />,
|
||||
activeRegExp: /^\/blacklist/,
|
||||
requiredPermission: [
|
||||
Permission.MANAGE_BLACKLIST,
|
||||
Permission.VIEW_BLACKLIST,
|
||||
],
|
||||
permissionType: 'or',
|
||||
},
|
||||
{
|
||||
href: '/issues',
|
||||
content: intl.formatMessage(menuMessages.issues),
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -42,6 +42,7 @@ const messages = defineMessages('components.RequestList.RequestItem', {
|
||||
tmdbid: 'TMDB ID',
|
||||
tvdbid: 'TheTVDB ID',
|
||||
unknowntitle: 'Unknown Title',
|
||||
removearr: 'Remove from {arr}',
|
||||
profileName: 'Profile',
|
||||
});
|
||||
|
||||
@@ -190,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"
|
||||
@@ -249,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"
|
||||
@@ -341,6 +344,18 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
revalidateList();
|
||||
};
|
||||
|
||||
const deleteMediaFile = async () => {
|
||||
if (request.media) {
|
||||
await fetch(`/api/v1/media/${request.media.id}/file`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
await fetch(`/api/v1/media/${request.media.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
revalidateList();
|
||||
}
|
||||
};
|
||||
|
||||
const retryRequest = async () => {
|
||||
setRetrying(true);
|
||||
|
||||
@@ -405,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' }}
|
||||
@@ -430,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}`
|
||||
@@ -557,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"
|
||||
@@ -616,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}
|
||||
@@ -666,14 +685,28 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
)}
|
||||
{requestData.status !== MediaRequestStatus.PENDING &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<ConfirmButton
|
||||
onClick={() => deleteRequest()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>{intl.formatMessage(messages.deleterequest)}</span>
|
||||
</ConfirmButton>
|
||||
<>
|
||||
<ConfirmButton
|
||||
onClick={() => deleteRequest()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>{intl.formatMessage(messages.deleterequest)}</span>
|
||||
</ConfirmButton>
|
||||
<ConfirmButton
|
||||
onClick={() => deleteMediaFile()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.removearr, {
|
||||
arr: request.type === 'movie' ? 'Radarr' : 'Sonarr',
|
||||
})}
|
||||
</span>
|
||||
</ConfirmButton>
|
||||
</>
|
||||
)}
|
||||
{requestData.status === MediaRequestStatus.PENDING &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -452,6 +452,7 @@ export const WatchProviderSelector = ({
|
||||
tabIndex={0}
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
||||
alt=""
|
||||
style={{
|
||||
@@ -497,6 +498,7 @@ export const WatchProviderSelector = ({
|
||||
tabIndex={0}
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
||||
alt=""
|
||||
style={{
|
||||
|
||||
@@ -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 = () => {
|
||||
@@ -82,6 +93,12 @@ const SettingsMain = () => {
|
||||
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
||||
(value) => !value || !value.endsWith('/')
|
||||
),
|
||||
proxyPort: Yup.number().when('proxyEnabled', {
|
||||
is: (proxyEnabled: boolean) => proxyEnabled,
|
||||
then: Yup.number().required(
|
||||
intl.formatMessage(messages.validationProxyPort)
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
const regenerate = async () => {
|
||||
@@ -137,6 +154,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 +183,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 +472,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">
|
||||
|
||||
@@ -86,10 +86,12 @@ interface TestResponse {
|
||||
id: number;
|
||||
path: string;
|
||||
}[];
|
||||
languageProfiles: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
languageProfiles:
|
||||
| {
|
||||
id: number;
|
||||
name: string;
|
||||
}[]
|
||||
| null;
|
||||
tags: {
|
||||
id: number;
|
||||
label: string;
|
||||
@@ -112,7 +114,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
const [testResponse, setTestResponse] = useState<TestResponse>({
|
||||
profiles: [],
|
||||
rootFolders: [],
|
||||
languageProfiles: [],
|
||||
languageProfiles: null,
|
||||
tags: [],
|
||||
});
|
||||
const SonarrSettingsSchema = Yup.object().shape({
|
||||
@@ -137,9 +139,11 @@ 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))
|
||||
.test(
|
||||
@@ -658,54 +662,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 +869,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)}
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -298,6 +298,7 @@
|
||||
"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",
|
||||
@@ -1030,6 +1031,7 @@
|
||||
"components.Settings.save": "Save Changes",
|
||||
"components.Settings.saving": "Saving…",
|
||||
"components.Settings.scan": "Sync Libraries",
|
||||
"components.Settings.scanbackground": "Scanning will run in the background. You can continue the setup process in the meantime.",
|
||||
"components.Settings.scanning": "Syncing…",
|
||||
"components.Settings.serverLocal": "local",
|
||||
"components.Settings.serverRemote": "remote",
|
||||
@@ -1050,6 +1052,7 @@
|
||||
"components.Settings.tautulliSettings": "Tautulli Settings",
|
||||
"components.Settings.tautulliSettingsDescription": "Optionally configure the settings for your Tautulli server. Jellyseerr fetches watch history data for your Plex media from Tautulli.",
|
||||
"components.Settings.timeout": "Timeout",
|
||||
"components.Settings.tip": "Tip",
|
||||
"components.Settings.toastPlexConnecting": "Attempting to connect to Plex…",
|
||||
"components.Settings.toastPlexConnectingFailure": "Failed to connect to Plex.",
|
||||
"components.Settings.toastPlexConnectingSuccess": "Plex connection established successfully!",
|
||||
@@ -1079,16 +1082,14 @@
|
||||
"components.Setup.continue": "Continue",
|
||||
"components.Setup.finish": "Finish Setup",
|
||||
"components.Setup.finishing": "Finishing…",
|
||||
"components.Setup.scanbackground": "Scanning will run in the background. You can continue the setup process in the meantime.",
|
||||
"components.Setup.servertype": "Choose Server Type",
|
||||
"components.Setup.setup": "Setup",
|
||||
"components.Setup.signin": "Sign In",
|
||||
"components.Setup.signin": "Sign in to your account",
|
||||
"components.Setup.signinMessage": "Get started by signing in",
|
||||
"components.Setup.signinWithEmby": "Enter your Emby details",
|
||||
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
|
||||
"components.Setup.signinWithPlex": "Enter your Plex details",
|
||||
"components.Setup.subtitle": "Get started by choosing your media server",
|
||||
"components.Setup.tip": "Tip",
|
||||
"components.Setup.welcome": "Welcome to Jellyseerr",
|
||||
"components.StatusBadge.managemedia": "Manage {mediaType}",
|
||||
"components.StatusBadge.openinarr": "Open in {arr}",
|
||||
@@ -1233,6 +1234,7 @@
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.saving": "Saving…",
|
||||
"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.toastSettingsSuccess": "Settings saved successfully!",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.user": "User",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID",
|
||||
@@ -1312,6 +1314,7 @@
|
||||
"components.UserProfile.seriesrequest": "Series Requests",
|
||||
"components.UserProfile.totalrequests": "Total Requests",
|
||||
"components.UserProfile.unlimited": "Unlimited",
|
||||
"i18n.addToBlacklist": "Add to Blacklist",
|
||||
"i18n.advanced": "Advanced",
|
||||
"i18n.all": "All",
|
||||
"i18n.approve": "Approve",
|
||||
|
||||
@@ -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';
|
||||
|
||||
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