Compare commits

..

16 Commits

Author SHA1 Message Date
JoaquinOlivero
c195a6f720 style: formatting 2024-09-18 03:02:41 +00:00
JoaquinOlivero
f7a4efdde1 fix: avatar images not showing on issues page 2024-09-18 02:45:55 +00:00
JoaquinOlivero
6a475b5e41 fix: remove log and correctly set the if statement for the cached image component 2024-09-18 02:45:43 +00:00
JoaquinOlivero
ae5542b6d3 refactor: only cache avatar with http url protocol 2024-09-18 02:45:41 +00:00
JoaquinOlivero
cbf3bbb17d fix: fix incomplete URL substring sanitization 2024-09-18 02:45:40 +00:00
JoaquinOlivero
14fdd4e293 fix: fix vulnerability 2024-09-18 02:45:39 +00:00
JoaquinOlivero
d2f651081a refactor: checks if the default avatar is cached to avoid creating duplicates for different users 2024-09-18 02:45:35 +00:00
JoaquinOlivero
822ca690da style: grammar 2024-09-18 02:44:04 +00:00
JoaquinOlivero
b2291f5125 refactor: use 'mime' package to detmerine file extension 2024-09-18 02:44:02 +00:00
JoaquinOlivero
84f7a9795e fix: requested changes 2024-09-18 02:44:01 +00:00
JoaquinOlivero
9c5f4fe2f0 fix: remove unexpired unused image when a user changes their avatar 2024-09-18 02:44:00 +00:00
JoaquinOlivero
3af39dd5f0 fix(s): set correct src URL for cached image 2024-09-18 02:43:58 +00:00
JoaquinOlivero
7ad44830d1 fix: show the correct avatar in the list of available users in advanced request 2024-09-18 02:43:57 +00:00
JoaquinOlivero
bd8deedfbe fix: set avatar image URL 2024-09-18 02:43:56 +00:00
JoaquinOlivero
548aaede26 fix: extract keys 2024-09-18 02:43:55 +00:00
JoaquinOlivero
8c7a08b4e9 refactor: proxy and cache user avatar images 2024-09-18 02:43:50 +00:00
72 changed files with 509 additions and 1109 deletions

View File

@@ -439,15 +439,6 @@
"contributions": [ "contributions": [
"code" "code"
] ]
},
{
"login": "M0NsTeRRR",
"name": "Ludovic Ortega",
"avatar_url": "https://avatars.githubusercontent.com/u/37785089?v=4",
"profile": "https://github.com/M0NsTeRRR",
"contributions": [
"security"
]
} }
] ]
} }

View File

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

2
.gitattributes vendored
View File

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

1
.gitignore vendored
View File

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

View File

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

View File

@@ -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="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> <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 --> <!-- 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-48-orange.svg"/></a> <a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-47-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
**Jellyseerr** is a free and open source software application for managing requests for your media library. **Jellyseerr** is a free and open source software application for managing requests for your media library.
@@ -146,7 +146,6 @@ 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/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/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/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> </tr>
</tbody> </tbody>
</table> </table>

View File

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

View File

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

View File

@@ -6,10 +6,6 @@ sidebar_position: 4
# AUR (Arch User Repository) # AUR (Arch User Repository)
:::note Disclaimer
This AUR package is not maintained by us but by a third party. Please refer to the maintainer for any issues.
:::
:::info :::info
This method is not recommended for most users. It is intended for advanced users who are using Arch Linux or an Arch-based distribution. This method is not recommended for most users. It is intended for advanced users who are using Arch Linux or an Arch-based distribution.
::: :::

View File

@@ -12,12 +12,49 @@ import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem'; import TabItem from '@theme/TabItem';
### Prerequisites ### 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/) - [Node.js 20.x](https://nodejs.org/en/download/)
- [Pnpm 9.x](https://pnpm.io/installation) - [Pnpm 9.x](https://pnpm.io/installation)
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
</TabItem>
</Tabs>
## Unix (Linux, macOS) ## Unix (Linux, macOS)
### Installation ### 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: 1. Assuming you want the working directory to be `/opt/jellyseerr`, create the directory and navigate to it:
```bash ```bash
sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr
@@ -40,6 +77,8 @@ pnpm build
```bash ```bash
pnpm start pnpm start
``` ```
</TabItem>
</Tabs>
:::info :::info
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser. You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
@@ -195,6 +234,33 @@ pm2 status jellyseerr
## Windows ## Windows
### Installation ### 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: 1. Assuming you want the working directory to be `C:\jellyseerr`, create the directory and navigate to it:
```powershell ```powershell
mkdir C:\jellyseerr mkdir C:\jellyseerr
@@ -218,6 +284,8 @@ pnpm build
```powershell ```powershell
pnpm start pnpm start
``` ```
</TabItem>
</Tabs>
:::tip :::tip
You can add the environment variables to a `.env` file in the Jellyseerr directory. You can add the environment variables to a `.env` file in the Jellyseerr directory.
@@ -245,7 +313,6 @@ node dist/index.js
- Set the trigger to "When the computer starts" - Set the trigger to "When the computer starts"
- Set the action to "Start a program" - Set the action to "Start a program"
- Set the program/script to the path of the `start-jellyseerr.bat` file - Set the program/script to the path of the `start-jellyseerr.bat` file
- Set the "Start in" to the jellyseerr directory.
- Click "Finish" - Click "Finish"
Now, Jellyseerr will start when the computer boots up in the background. Now, Jellyseerr will start when the computer boots up in the background.

View File

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

2
next-env.d.ts vendored
View File

@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. // see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -1988,9 +1988,6 @@ paths:
appDataPath: appDataPath:
type: string type: string
example: /app/config example: /app/config
appDataPermissions:
type: boolean
example: true
/settings/main: /settings/main:
get: get:
summary: Get main settings summary: Get main settings

View File

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

65
pnpm-lock.yaml generated
View File

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

View File

@@ -1,5 +1,5 @@
import type { RateLimitOptions } from '@server/utils/rateLimit'; import type { RateLimitOptions } from '@server/utils/rateLimit';
// import rateLimit from '@server/utils/rateLimit'; import rateLimit from '@server/utils/rateLimit';
import type NodeCache from 'node-cache'; import type NodeCache from 'node-cache';
// 5 minute default TTL (in seconds) // 5 minute default TTL (in seconds)
@@ -26,34 +26,19 @@ class ExternalAPI {
params: Record<string, string> = {}, params: Record<string, string> = {},
options: ExternalAPIOptions = {} options: ExternalAPIOptions = {}
) { ) {
// if (options.rateLimit) { if (options.rateLimit) {
// this.fetch = rateLimit(fetch, options.rateLimit); this.fetch = rateLimit(fetch, options.rateLimit);
// } else { } else {
// this.fetch = fetch; this.fetch = fetch;
// }
this.fetch = fetch;
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.baseUrl = baseUrl;
this.params = params; this.params = params;
this.defaultHeaders = {
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
};
this.cache = options.nodeCache; this.cache = options.nodeCache;
} }
@@ -91,7 +76,7 @@ class ExternalAPI {
} }
const data = await this.getDataFromResponse(response); const data = await this.getDataFromResponse(response);
if (this.cache && ttl !== 0) { if (this.cache) {
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL); this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
} }
@@ -135,7 +120,7 @@ class ExternalAPI {
} }
const resData = await this.getDataFromResponse(response); const resData = await this.getDataFromResponse(response);
if (this.cache && ttl !== 0) { if (this.cache) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL); this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
} }
@@ -179,7 +164,7 @@ class ExternalAPI {
} }
const resData = await this.getDataFromResponse(response); const resData = await this.getDataFromResponse(response);
if (this.cache && ttl !== 0) { if (this.cache) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL); this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
} }

View File

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

View File

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

View File

@@ -182,7 +182,7 @@ class RottenTomatoes extends ExternalAPI {
); );
} }
if (!tvshow || !tvshow.rottenTomatoes) { if (!tvshow) {
return null; return null;
} }

View File

@@ -157,13 +157,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => { public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
try { try {
const data = await this.get<QueueResponse<QueueItemAppendT>>( const data = await this.get<QueueResponse<QueueItemAppendT>>(`/queue`, {
`/queue`, includeEpisode: 'true',
{ });
includeEpisode: 'true',
},
0
);
return data.records; return data.records;
} catch (e) { } catch (e) {
@@ -197,24 +193,15 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
} }
}; };
async refreshMonitoredDownloads(): Promise<void> {
await this.runCommand('RefreshMonitoredDownloads', {});
}
protected async runCommand( protected async runCommand(
commandName: string, commandName: string,
options: Record<string, unknown> options: Record<string, unknown>
): Promise<void> { ): Promise<void> {
try { try {
await this.post( await this.post(`/command`, {
`/command`, name: commandName,
{ ...options,
name: commandName, });
...options,
},
{},
0
);
} catch (e) { } catch (e) {
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`); throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
} }

View File

@@ -231,7 +231,7 @@ class Media {
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`; this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
} }
if (this.jellyfinMediaId4k) { if (this.jellyfinMediaId4k) {
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`; this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
} }
} }
} }

View File

@@ -21,9 +21,7 @@ import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes'; import routes from '@server/routes';
import avatarproxy from '@server/routes/avatarproxy'; import avatarproxy from '@server/routes/avatarproxy';
import imageproxy from '@server/routes/imageproxy'; import imageproxy from '@server/routes/imageproxy';
import { appDataPermissions } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion'; import { getAppVersion } from '@server/utils/appVersion';
import createCustomProxyAgent from '@server/utils/customProxyAgent';
import restartFlag from '@server/utils/restartFlag'; import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip'; import { getClientIp } from '@supercharge/request-ip';
import { TypeormStore } from 'connect-typeorm/out'; import { TypeormStore } from 'connect-typeorm/out';
@@ -53,12 +51,6 @@ const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev }); const app = next({ dev });
const handle = app.getRequestHandler(); const handle = app.getRequestHandler();
if (!appDataPermissions()) {
logger.error(
'Something went wrong while checking config folder! Please ensure the config folder is set up properly.\nhttps://docs.jellyseerr.dev/getting-started'
);
}
app app
.prepare() .prepare()
.then(async () => { .then(async () => {
@@ -75,11 +67,6 @@ app
const settings = await getSettings().load(); const settings = await getSettings().load();
restartFlag.initializeSettings(settings.main); restartFlag.initializeSettings(settings.main);
// Register HTTP proxy
if (settings.main.proxy.enabled) {
await createCustomProxyAgent(settings.main.proxy);
}
// Migrate library types // Migrate library types
if ( if (
settings.plex.libraries.length > 1 && settings.plex.libraries.length > 1 &&
@@ -188,7 +175,7 @@ app
}, },
store: new TypeormStore({ store: new TypeormStore({
cleanupLimit: 2, cleanupLimit: 2,
ttl: 60 * 60 * 24 * 30, ttl: 1000 * 60 * 60 * 24 * 30,
}).connect(sessionRespository) as Store, }).connect(sessionRespository) as Store,
}) })
); );

View File

@@ -85,7 +85,6 @@ class DownloadTracker {
}); });
try { try {
await radarr.refreshMonitoredDownloads();
const queueItems = await radarr.getQueue(); const queueItems = await radarr.getQueue();
this.radarrServers[server.id] = queueItems.map((item) => ({ this.radarrServers[server.id] = queueItems.map((item) => ({
@@ -163,7 +162,6 @@ class DownloadTracker {
}); });
try { try {
await sonarr.refreshMonitoredDownloads();
const queueItems = await sonarr.getQueue(); const queueItems = await sonarr.getQueue();
this.sonarrServers[server.id] = queueItems.map((item) => ({ this.sonarrServers[server.id] = queueItems.map((item) => ({

View File

@@ -1,6 +1,6 @@
import logger from '@server/logger'; import logger from '@server/logger';
import type { RateLimitOptions } from '@server/utils/rateLimit'; import type { RateLimitOptions } from '@server/utils/rateLimit';
// import rateLimit from '@server/utils/rateLimit'; import rateLimit from '@server/utils/rateLimit';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { promises } from 'fs'; import { promises } from 'fs';
import mime from 'mime/lite'; import mime from 'mime/lite';
@@ -135,7 +135,6 @@ class ImageProxy {
private cacheVersion; private cacheVersion;
private key; private key;
private baseUrl; private baseUrl;
private headers: HeadersInit | null = null;
constructor( constructor(
key: string, key: string,
@@ -143,28 +142,22 @@ class ImageProxy {
options: { options: {
cacheVersion?: number; cacheVersion?: number;
rateLimitOptions?: RateLimitOptions; rateLimitOptions?: RateLimitOptions;
headers?: HeadersInit;
} = {} } = {}
) { ) {
this.cacheVersion = options.cacheVersion ?? 1; this.cacheVersion = options.cacheVersion ?? 1;
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.key = key; this.key = key;
// if (options.rateLimitOptions) { if (options.rateLimitOptions) {
// this.fetch = rateLimit(fetch, { this.fetch = rateLimit(fetch, {
// ...options.rateLimitOptions, ...options.rateLimitOptions,
// }); });
// } else { } else {
// this.fetch = fetch; this.fetch = fetch;
// } }
this.fetch = fetch;
this.headers = options.headers || null;
} }
public async getImage( public async getImage(path: string): Promise<ImageResponse> {
path: string,
fallbackPath?: string
): Promise<ImageResponse> {
const cacheKey = this.getCacheKey(path); const cacheKey = this.getCacheKey(path);
const imageResponse = await this.get(cacheKey); const imageResponse = await this.get(cacheKey);
@@ -173,11 +166,7 @@ class ImageProxy {
const newImage = await this.set(path, cacheKey); const newImage = await this.set(path, cacheKey);
if (!newImage) { if (!newImage) {
if (fallbackPath) { throw new Error('Failed to load image');
return await this.getImage(fallbackPath);
} else {
throw new Error('Failed to load image');
}
} }
return newImage; return newImage;
@@ -258,12 +247,7 @@ class ImageProxy {
: '/' : '/'
: '') + : '') +
(path.startsWith('/') ? path.slice(1) : path); (path.startsWith('/') ? path.slice(1) : path);
const response = await this.fetch(href, { const response = await this.fetch(href);
headers: this.headers || undefined,
});
if (!response.ok) {
return null;
}
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);

View File

@@ -129,7 +129,7 @@ class PlexScanner
}); });
settings.plex.libraries = newLibraries; settings.plex.libraries = newLibraries;
await settings.save(); settings.save();
} }
} else { } else {
for (const library of this.libraries) { for (const library of this.libraries) {

View File

@@ -2,7 +2,7 @@ import { MediaServerType } from '@server/constants/server';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import { runMigrations } from '@server/lib/settings/migrator'; import { runMigrations } from '@server/lib/settings/migrator';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import fs from 'fs/promises'; import fs from 'fs';
import { merge } from 'lodash'; import { merge } from 'lodash';
import path from 'path'; import path from 'path';
import webpush from 'web-push'; import webpush from 'web-push';
@@ -99,17 +99,6 @@ interface Quota {
quotaDays?: number; quotaDays?: number;
} }
export interface ProxySettings {
enabled: boolean;
hostname: string;
port: number;
useSsl: boolean;
user: string;
password: string;
bypassFilter: string;
bypassLocalAddresses: boolean;
}
export interface MainSettings { export interface MainSettings {
apiKey: string; apiKey: string;
applicationTitle: string; applicationTitle: string;
@@ -130,7 +119,6 @@ export interface MainSettings {
mediaServerType: number; mediaServerType: number;
partialRequestsEnabled: boolean; partialRequestsEnabled: boolean;
locale: string; locale: string;
proxy: ProxySettings;
} }
interface PublicSettings { interface PublicSettings {
@@ -337,16 +325,6 @@ class Settings {
mediaServerType: MediaServerType.NOT_CONFIGURED, mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true, partialRequestsEnabled: true,
locale: 'en', locale: 'en',
proxy: {
enabled: false,
hostname: '',
port: 8080,
useSsl: false,
user: '',
password: '',
bypassFilter: '',
bypassLocalAddresses: true,
},
}, },
plex: { plex: {
name: '', name: '',
@@ -501,6 +479,10 @@ class Settings {
} }
get main(): MainSettings { get main(): MainSettings {
if (!this.data.main.apiKey) {
this.data.main.apiKey = this.generateApiKey();
this.save();
}
return this.data.main; return this.data.main;
} }
@@ -602,20 +584,29 @@ class Settings {
} }
get clientId(): string { get clientId(): string {
if (!this.data.clientId) {
this.data.clientId = randomUUID();
this.save();
}
return this.data.clientId; return this.data.clientId;
} }
get vapidPublic(): string { get vapidPublic(): string {
this.generateVapidKeys();
return this.data.vapidPublic; return this.data.vapidPublic;
} }
get vapidPrivate(): string { get vapidPrivate(): string {
this.generateVapidKeys();
return this.data.vapidPrivate; return this.data.vapidPrivate;
} }
public async regenerateApiKey(): Promise<MainSettings> { public regenerateApiKey(): MainSettings {
this.main.apiKey = this.generateApiKey(); this.main.apiKey = this.generateApiKey();
await this.save(); this.save();
return this.main; return this.main;
} }
@@ -627,6 +618,15 @@ class Settings {
} }
} }
private generateVapidKeys(force = false): void {
if (!this.data.vapidPublic || !this.data.vapidPrivate || force) {
const vapidKeys = webpush.generateVAPIDKeys();
this.data.vapidPrivate = vapidKeys.privateKey;
this.data.vapidPublic = vapidKeys.publicKey;
this.save();
}
}
/** /**
* Settings Load * Settings Load
* *
@@ -641,51 +641,30 @@ class Settings {
return this; return this;
} }
let data; if (!fs.existsSync(SETTINGS_PATH)) {
try { this.save();
data = await fs.readFile(SETTINGS_PATH, 'utf-8');
} catch {
await this.save();
} }
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
if (data) { if (data) {
const parsedJson = JSON.parse(data); const parsedJson = JSON.parse(data);
const migratedData = await runMigrations(parsedJson, SETTINGS_PATH); this.data = await runMigrations(parsedJson);
this.data = merge(this.data, migratedData);
}
// generate keys and ids if it's missing this.data = merge(this.data, parsedJson);
let change = false;
if (!this.data.main.apiKey) { if (process.env.API_KEY) {
this.data.main.apiKey = this.generateApiKey(); if (this.main.apiKey != process.env.API_KEY) {
change = true; this.main.apiKey = process.env.API_KEY;
} else if (process.env.API_KEY) { }
if (this.main.apiKey != process.env.API_KEY) {
this.main.apiKey = process.env.API_KEY;
} }
}
if (!this.data.clientId) {
this.data.clientId = randomUUID();
change = true;
}
if (!this.data.vapidPublic || !this.data.vapidPrivate) {
const vapidKeys = webpush.generateVAPIDKeys();
this.data.vapidPrivate = vapidKeys.privateKey;
this.data.vapidPublic = vapidKeys.publicKey;
change = true;
}
if (change) {
await this.save();
}
this.save();
}
return this; return this;
} }
public async save(): Promise<void> { public save(): void {
await fs.writeFile( fs.writeFileSync(SETTINGS_PATH, JSON.stringify(this.data, undefined, ' '));
SETTINGS_PATH,
JSON.stringify(this.data, undefined, ' ')
);
} }
} }

View File

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

View File

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

View File

@@ -1,100 +1,30 @@
import type { AllSettings } from '@server/lib/settings'; import type { AllSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import fs from 'fs/promises'; import fs from 'fs';
import path from 'path'; import path from 'path';
const migrationsDir = path.join(__dirname, 'migrations'); const migrationsDir = path.join(__dirname, 'migrations');
export const runMigrations = async ( export const runMigrations = async (
settings: AllSettings, settings: AllSettings
SETTINGS_PATH: string
): Promise<AllSettings> => { ): Promise<AllSettings> => {
const migrations = fs
.readdirSync(migrationsDir)
.filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
// eslint-disable-next-line @typescript-eslint/no-var-requires
.map((file) => require(path.join(migrationsDir, file)).default);
let migrated = settings; let migrated = settings;
try { try {
// we read old backup and create a backup of currents settings
const BACKUP_PATH = SETTINGS_PATH.replace('.json', '.old.json');
let oldBackup: string | null = null;
try {
oldBackup = await fs.readFile(BACKUP_PATH, 'utf-8');
} catch {
/* empty */
}
await fs.writeFile(BACKUP_PATH, JSON.stringify(settings, undefined, ' '));
const migrations = (await fs.readdir(migrationsDir)).filter(
(file) => file.endsWith('.js') || file.endsWith('.ts')
);
const settingsBefore = JSON.stringify(migrated);
for (const migration of migrations) { for (const migration of migrations) {
try { migrated = await migration(migrated);
logger.debug(`Checking migration '${migration}'...`, {
label: 'Settings Migrator',
});
const { default: migrationFn } = await import(
path.join(migrationsDir, migration)
);
const newSettings = await migrationFn(structuredClone(migrated));
if (JSON.stringify(migrated) !== JSON.stringify(newSettings)) {
logger.debug(`Migration '${migration}' has been applied.`, {
label: 'Settings Migrator',
});
}
migrated = newSettings;
} catch (e) {
// we stop jellyseerr if the migration failed
logger.error(
`Error while running migration '${migration}': ${e.message}`,
{
label: 'Settings Migrator',
}
);
logger.error(
'A common cause for this error is a permission issue with your configuration folder, a network issue or a corrupted database.',
{
label: 'Settings Migrator',
}
);
process.exit();
}
}
const settingsAfter = JSON.stringify(migrated);
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) { } catch (e) {
// we stop jellyseerr if the migration failed
logger.error( logger.error(
`Something went wrong while running settings migrations: ${e.message}`, `Something went wrong while running settings migrations: ${e.message}`,
{ { label: 'Settings Migrator' }
label: 'Settings Migrator',
}
); );
logger.error(
'A common cause for this issue is a permission error of your configuration folder.',
{
label: 'Settings Migrator',
}
);
process.exit();
} }
return migrated; return migrated;

View File

@@ -6,6 +6,7 @@ import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User'; import { User } from '@server/entity/User';
import { startJobs } from '@server/job/schedule'; import { startJobs } from '@server/job/schedule';
import ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
@@ -14,6 +15,7 @@ import { ApiError } from '@server/types/error';
import { getHostname } from '@server/utils/getHostname'; import { getHostname } from '@server/utils/getHostname';
import * as EmailValidator from 'email-validator'; import * as EmailValidator from 'email-validator';
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import net from 'net'; import net from 'net';
const authRoutes = Router(); const authRoutes = Router();
@@ -87,7 +89,7 @@ authRoutes.post('/plex', async (req, res, next) => {
}); });
settings.main.mediaServerType = MediaServerType.PLEX; settings.main.mediaServerType = MediaServerType.PLEX;
await settings.save(); settings.save();
startJobs(); startJobs();
await userRepository.save(user); await userRepository.save(user);
@@ -260,6 +262,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
urlBase: body.urlBase, urlBase: body.urlBase,
}); });
const { externalHostname } = getSettings().jellyfin;
// Try to find deviceId that corresponds to jellyfin user, else generate a new one // Try to find deviceId that corresponds to jellyfin user, else generate a new one
let user = await userRepository.findOne({ let user = await userRepository.findOne({
where: { jellyfinUsername: body.username }, where: { jellyfinUsername: body.username },
@@ -277,6 +281,11 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
// First we need to attempt to log the user in to jellyfin // First we need to attempt to log the user in to jellyfin
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId); const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
const ip = req.ip; const ip = req.ip;
let clientIp; let clientIp;
@@ -299,84 +308,64 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
where: { jellyfinUserId: account.User.Id }, where: { jellyfinUserId: account.User.Id },
}); });
const missingAdminUser = !user && !(await userRepository.count()); if (!user && !(await userRepository.count())) {
if (
missingAdminUser ||
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED
) {
// Check if user is admin on jellyfin // Check if user is admin on jellyfin
if (account.User.Policy.IsAdministrator === false) { if (account.User.Policy.IsAdministrator === false) {
throw new ApiError(403, ApiErrorCode.NotAdmin); throw new ApiError(403, ApiErrorCode.NotAdmin);
} }
if ( logger.info(
body.serverType !== MediaServerType.JELLYFIN && 'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr',
body.serverType !== MediaServerType.EMBY {
) { label: 'API',
throw new Error('select_server_type'); ip: req.ip,
}
settings.main.mediaServerType = body.serverType;
if (missingAdminUser) {
logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Jellyseerr',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
// User doesn't exist, and there are no users in the database, we'll create the user
// with admin permissions
user = new User({
id: 1,
email: body.email || account.User.Name,
jellyfinUsername: account.User.Name, jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`,
userType:
body.serverType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
});
await userRepository.save(user);
} else {
logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; editing admin user for Jellyseerr',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
// User alread exist but settings.json is not configured, we'll edit the admin user
user = await userRepository.findOne({
where: { id: 1 },
});
if (!user) {
throw new Error('Unable to find admin user to edit');
} }
user.email = body.email || account.User.Name; );
user.jellyfinUsername = account.User.Name;
user.jellyfinUserId = account.User.Id;
user.jellyfinDeviceId = deviceId;
user.jellyfinAuthToken = account.AccessToken;
user.permissions = Permission.ADMIN;
user.avatar = `/avatarproxy/${account.User.Id}`;
user.userType =
body.serverType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY;
await userRepository.save(user); // User doesn't exist, and there are no users in the database, we'll create the user
// with admin permissions
switch (body.serverType) {
case MediaServerType.EMBY:
settings.main.mediaServerType = MediaServerType.EMBY;
user = new User({
email: body.email || account.User.Name,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: account.User.PrimaryImageTag
? `${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');
} }
// Create an API key on Jellyfin from this admin user // Create an API key on Jellyfin from this admin user
@@ -396,8 +385,10 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
settings.jellyfin.urlBase = body.urlBase ?? ''; settings.jellyfin.urlBase = body.urlBase ?? '';
settings.jellyfin.useSsl = body.useSsl ?? false; settings.jellyfin.useSsl = body.useSsl ?? false;
settings.jellyfin.apiKey = apiKey; settings.jellyfin.apiKey = apiKey;
await settings.save(); settings.save();
startJobs(); startJobs();
await userRepository.save(user);
} }
// User already exists, let's update their information // User already exists, let's update their information
else if (account.User.Id === user?.jellyfinUserId) { else if (account.User.Id === user?.jellyfinUserId) {
@@ -417,7 +408,27 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUsername: account.User.Name, jellyfinUsername: account.User.Name,
} }
); );
user.avatar = `/avatarproxy/${account.User.Id}`; // Update the users avatar with their jellyfin profile pic (incase it changed)
if (account.User.PrimaryImageTag) {
const avatar = `${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.jellyfinUsername = account.User.Name; user.jellyfinUsername = account.User.Name;
if (user.username === account.User.Name) { if (user.username === account.User.Name) {
@@ -455,7 +466,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUserId: account.User.Id, jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId, jellyfinDeviceId: deviceId,
permissions: settings.main.defaultPermissions, permissions: settings.main.defaultPermissions,
avatar: `/avatarproxy/${account.User.Id}`, avatar: account.User.PrimaryImageTag
? `${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:
settings.main.mediaServerType === MediaServerType.JELLYFIN settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN ? UserType.JELLYFIN

View File

@@ -1,71 +1,16 @@
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import ImageProxy from '@server/lib/imageproxy'; import ImageProxy from '@server/lib/imageproxy';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { getAppVersion } from '@server/utils/appVersion';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
const router = Router(); const router = Router();
let _avatarImageProxy: ImageProxy | null = null; const avatarImageProxy = new ImageProxy('avatar', '');
async function initAvatarImageProxy() { // Proxy avatar images
if (!_avatarImageProxy) { router.get('/*', async (req, res) => {
const userRepository = getRepository(User); const imagePath = req.url.startsWith('/') ? req.url.slice(1) : req.url;
const admin = await userRepository.findOne({
where: { id: 1 },
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' },
});
const deviceId = admin?.jellyfinDeviceId;
const authToken = getSettings().jellyfin.apiKey;
_avatarImageProxy = new ImageProxy('avatar', '', {
headers: {
'X-Emby-Authorization': `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`,
},
});
}
return _avatarImageProxy;
}
router.get('/:jellyfinUserId', async (req, res) => {
try { try {
if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) { const imageData = await avatarImageProxy.getImage(imagePath);
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, { res.writeHead(200, {
'Content-Type': `image/${imageData.meta.extension}`, 'Content-Type': `image/${imageData.meta.extension}`,
@@ -78,6 +23,7 @@ router.get('/:jellyfinUserId', async (req, res) => {
res.end(imageData.imageBuffer); res.end(imageData.imageBuffer);
} catch (e) { } catch (e) {
logger.error('Failed to proxy avatar image', { logger.error('Failed to proxy avatar image', {
imagePath,
errorMessage: e.message, errorMessage: e.message,
}); });
} }

View File

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

View File

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

View File

@@ -32,6 +32,7 @@ import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express'; import { Router } from 'express';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import fs from 'fs'; import fs from 'fs';
import gravatarUrl from 'gravatar-url';
import { escapeRegExp, merge, omit, set, sortBy } from 'lodash'; import { escapeRegExp, merge, omit, set, sortBy } from 'lodash';
import { rescheduleJob } from 'node-schedule'; import { rescheduleJob } from 'node-schedule';
import path from 'path'; import path from 'path';
@@ -69,19 +70,19 @@ settingsRoutes.get('/main', (req, res, next) => {
res.status(200).json(filteredMainSettings(req.user, settings.main)); res.status(200).json(filteredMainSettings(req.user, settings.main));
}); });
settingsRoutes.post('/main', async (req, res) => { settingsRoutes.post('/main', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.main = merge(settings.main, req.body); settings.main = merge(settings.main, req.body);
await settings.save(); settings.save();
return res.status(200).json(settings.main); return res.status(200).json(settings.main);
}); });
settingsRoutes.post('/main/regenerate', async (req, res, next) => { settingsRoutes.post('/main/regenerate', (req, res, next) => {
const settings = getSettings(); const settings = getSettings();
const main = await settings.regenerateApiKey(); const main = settings.regenerateApiKey();
if (!req.user) { if (!req.user) {
return next({ status: 500, message: 'User missing from request.' }); return next({ status: 500, message: 'User missing from request.' });
@@ -118,7 +119,7 @@ settingsRoutes.post('/plex', async (req, res, next) => {
settings.plex.machineId = result.MediaContainer.machineIdentifier; settings.plex.machineId = result.MediaContainer.machineIdentifier;
settings.plex.name = result.MediaContainer.friendlyName; settings.plex.name = result.MediaContainer.friendlyName;
await settings.save(); settings.save();
} catch (e) { } catch (e) {
logger.error('Something went wrong testing Plex connection', { logger.error('Something went wrong testing Plex connection', {
label: 'API', label: 'API',
@@ -231,7 +232,7 @@ settingsRoutes.get('/plex/library', async (req, res) => {
...library, ...library,
enabled: enabledLibraries.includes(library.id), enabled: enabledLibraries.includes(library.id),
})); }));
await settings.save(); settings.save();
return res.status(200).json(settings.plex.libraries); return res.status(200).json(settings.plex.libraries);
}); });
@@ -282,7 +283,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
Object.assign(settings.jellyfin, req.body); Object.assign(settings.jellyfin, req.body);
settings.jellyfin.serverId = result.Id; settings.jellyfin.serverId = result.Id;
settings.jellyfin.name = result.ServerName; settings.jellyfin.name = result.ServerName;
await settings.save(); settings.save();
} catch (e) { } catch (e) {
if (e instanceof ApiError) { if (e instanceof ApiError) {
logger.error('Something went wrong testing Jellyfin connection', { logger.error('Something went wrong testing Jellyfin connection', {
@@ -370,12 +371,17 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
...library, ...library,
enabled: enabledLibraries.includes(library.id), enabled: enabledLibraries.includes(library.id),
})); }));
await settings.save(); settings.save();
return res.status(200).json(settings.jellyfin.libraries); return res.status(200).json(settings.jellyfin.libraries);
}); });
settingsRoutes.get('/jellyfin/users', async (req, res) => { settingsRoutes.get('/jellyfin/users', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
const { externalHostname } = settings.jellyfin;
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: getHostname();
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
@@ -394,7 +400,9 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
const users = resp.users.map((user) => ({ const users = resp.users.map((user) => ({
username: user.Name, username: user.Name,
id: user.Id, id: user.Id,
thumb: `/avatarproxy/${user.Id}`, thumb: user.PrimaryImageTag
? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
: gravatarUrl(user.Name, { default: 'mm', size: 200 }),
email: user.Name, email: user.Name,
})); }));
@@ -434,7 +442,7 @@ settingsRoutes.post('/tautulli', async (req, res, next) => {
throw new Error('Tautulli version not supported'); throw new Error('Tautulli version not supported');
} }
await settings.save(); settings.save();
} catch (e) { } catch (e) {
logger.error('Something went wrong testing Tautulli connection', { logger.error('Something went wrong testing Tautulli connection', {
label: 'API', label: 'API',
@@ -695,7 +703,7 @@ settingsRoutes.post<{ jobId: JobId }>(
settingsRoutes.post<{ jobId: JobId }>( settingsRoutes.post<{ jobId: JobId }>(
'/jobs/:jobId/schedule', '/jobs/:jobId/schedule',
async (req, res, next) => { (req, res, next) => {
const scheduledJob = scheduledJobs.find( const scheduledJob = scheduledJobs.find(
(job) => job.id === req.params.jobId (job) => job.id === req.params.jobId
); );
@@ -709,7 +717,7 @@ settingsRoutes.post<{ jobId: JobId }>(
if (result) { if (result) {
settings.jobs[scheduledJob.id].schedule = req.body.schedule; settings.jobs[scheduledJob.id].schedule = req.body.schedule;
await settings.save(); settings.save();
scheduledJob.cronSchedule = req.body.schedule; scheduledJob.cronSchedule = req.body.schedule;
@@ -766,11 +774,11 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
settingsRoutes.post( settingsRoutes.post(
'/initialize', '/initialize',
isAuthenticated(Permission.ADMIN), isAuthenticated(Permission.ADMIN),
async (_req, res) => { (_req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.public.initialized = true; settings.public.initialized = true;
await settings.save(); settings.save();
return res.status(200).json(settings.public); return res.status(200).json(settings.public);
} }

View File

@@ -31,11 +31,11 @@ notificationRoutes.get('/discord', (_req, res) => {
res.status(200).json(settings.notifications.agents.discord); res.status(200).json(settings.notifications.agents.discord);
}); });
notificationRoutes.post('/discord', async (req, res) => { notificationRoutes.post('/discord', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.discord = req.body; settings.notifications.agents.discord = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.discord); res.status(200).json(settings.notifications.agents.discord);
}); });
@@ -65,11 +65,11 @@ notificationRoutes.get('/slack', (_req, res) => {
res.status(200).json(settings.notifications.agents.slack); res.status(200).json(settings.notifications.agents.slack);
}); });
notificationRoutes.post('/slack', async (req, res) => { notificationRoutes.post('/slack', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.slack = req.body; settings.notifications.agents.slack = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.slack); res.status(200).json(settings.notifications.agents.slack);
}); });
@@ -99,11 +99,11 @@ notificationRoutes.get('/telegram', (_req, res) => {
res.status(200).json(settings.notifications.agents.telegram); res.status(200).json(settings.notifications.agents.telegram);
}); });
notificationRoutes.post('/telegram', async (req, res) => { notificationRoutes.post('/telegram', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.telegram = req.body; settings.notifications.agents.telegram = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.telegram); res.status(200).json(settings.notifications.agents.telegram);
}); });
@@ -133,11 +133,11 @@ notificationRoutes.get('/pushbullet', (_req, res) => {
res.status(200).json(settings.notifications.agents.pushbullet); res.status(200).json(settings.notifications.agents.pushbullet);
}); });
notificationRoutes.post('/pushbullet', async (req, res) => { notificationRoutes.post('/pushbullet', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.pushbullet = req.body; settings.notifications.agents.pushbullet = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.pushbullet); res.status(200).json(settings.notifications.agents.pushbullet);
}); });
@@ -167,11 +167,11 @@ notificationRoutes.get('/pushover', (_req, res) => {
res.status(200).json(settings.notifications.agents.pushover); res.status(200).json(settings.notifications.agents.pushover);
}); });
notificationRoutes.post('/pushover', async (req, res) => { notificationRoutes.post('/pushover', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.pushover = req.body; settings.notifications.agents.pushover = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.pushover); res.status(200).json(settings.notifications.agents.pushover);
}); });
@@ -201,11 +201,11 @@ notificationRoutes.get('/email', (_req, res) => {
res.status(200).json(settings.notifications.agents.email); res.status(200).json(settings.notifications.agents.email);
}); });
notificationRoutes.post('/email', async (req, res) => { notificationRoutes.post('/email', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.email = req.body; settings.notifications.agents.email = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.email); res.status(200).json(settings.notifications.agents.email);
}); });
@@ -235,11 +235,11 @@ notificationRoutes.get('/webpush', (_req, res) => {
res.status(200).json(settings.notifications.agents.webpush); res.status(200).json(settings.notifications.agents.webpush);
}); });
notificationRoutes.post('/webpush', async (req, res) => { notificationRoutes.post('/webpush', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.webpush = req.body; settings.notifications.agents.webpush = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.webpush); res.status(200).json(settings.notifications.agents.webpush);
}); });
@@ -284,7 +284,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
res.status(200).json(response); res.status(200).json(response);
}); });
notificationRoutes.post('/webhook', async (req, res, next) => { notificationRoutes.post('/webhook', (req, res, next) => {
const settings = getSettings(); const settings = getSettings();
try { try {
JSON.parse(req.body.options.jsonPayload); JSON.parse(req.body.options.jsonPayload);
@@ -300,7 +300,7 @@ notificationRoutes.post('/webhook', async (req, res, next) => {
authHeader: req.body.options.authHeader, authHeader: req.body.options.authHeader,
}, },
}; };
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.webhook); res.status(200).json(settings.notifications.agents.webhook);
} catch (e) { } catch (e) {
@@ -351,11 +351,11 @@ notificationRoutes.get('/lunasea', (_req, res) => {
res.status(200).json(settings.notifications.agents.lunasea); res.status(200).json(settings.notifications.agents.lunasea);
}); });
notificationRoutes.post('/lunasea', async (req, res) => { notificationRoutes.post('/lunasea', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.lunasea = req.body; settings.notifications.agents.lunasea = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.lunasea); res.status(200).json(settings.notifications.agents.lunasea);
}); });
@@ -385,11 +385,11 @@ notificationRoutes.get('/gotify', (_req, res) => {
res.status(200).json(settings.notifications.agents.gotify); res.status(200).json(settings.notifications.agents.gotify);
}); });
notificationRoutes.post('/gotify', async (req, res) => { notificationRoutes.post('/gotify', (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.gotify = req.body; settings.notifications.agents.gotify = req.body;
await settings.save(); settings.save();
res.status(200).json(settings.notifications.agents.gotify); res.status(200).json(settings.notifications.agents.gotify);
}); });

View File

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

View File

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

View File

@@ -516,6 +516,12 @@ router.post(
//const jellyfinUsersResponse = await jellyfinClient.getUsers(); //const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = []; const createdUsers: User[] = [];
const { externalHostname } = getSettings().jellyfin;
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const jellyfinUsers = await jellyfinClient.getUsers(); const jellyfinUsers = await jellyfinClient.getUsers();
@@ -539,7 +545,12 @@ router.post(
).toString('base64'), ).toString('base64'),
email: jellyfinUser?.Name, email: jellyfinUser?.Name,
permissions: settings.main.defaultPermissions, permissions: settings.main.defaultPermissions,
avatar: `/avatarproxy/${jellyfinUser?.Id}`, avatar: jellyfinUser?.PrimaryImageTag
? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
: gravatarUrl(jellyfinUser?.Name ?? '', {
default: 'mm',
size: 200,
}),
userType: userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN ? UserType.JELLYFIN

View File

@@ -1,4 +1,4 @@
import { accessSync, existsSync } from 'fs'; import { existsSync } from 'fs';
import path from 'path'; import path from 'path';
const CONFIG_PATH = process.env.CONFIG_DIRECTORY const CONFIG_PATH = process.env.CONFIG_DIRECTORY
@@ -14,12 +14,3 @@ export const appDataStatus = (): boolean => {
export const appDataPath = (): string => { export const appDataPath = (): string => {
return CONFIG_PATH; return CONFIG_PATH;
}; };
export const appDataPermissions = (): boolean => {
try {
accessSync(CONFIG_PATH);
return true;
} catch (err) {
return false;
}
};

View File

@@ -1,111 +0,0 @@
import type { ProxySettings } from '@server/lib/settings';
import logger from '@server/logger';
import type { Dispatcher } from 'undici';
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
export default async function createCustomProxyAgent(
proxySettings: ProxySettings
) {
const defaultAgent = new Agent();
const skipUrl = (url: string) => {
const hostname = new URL(url).hostname;
if (proxySettings.bypassLocalAddresses && isLocalAddress(hostname)) {
return true;
}
for (const address of proxySettings.bypassFilter.split(',')) {
const trimmedAddress = address.trim();
if (!trimmedAddress) {
continue;
}
if (trimmedAddress.startsWith('*')) {
const domain = trimmedAddress.slice(1);
if (hostname.endsWith(domain)) {
return true;
}
} else if (hostname === trimmedAddress) {
return true;
}
}
return false;
};
const noProxyInterceptor = (
dispatch: Dispatcher['dispatch']
): Dispatcher['dispatch'] => {
return (opts, handler) => {
const url = opts.origin?.toString();
return url && skipUrl(url)
? defaultAgent.dispatch(opts, handler)
: dispatch(opts, handler);
};
};
const token =
proxySettings.user && proxySettings.password
? `Basic ${Buffer.from(
`${proxySettings.user}:${proxySettings.password}`
).toString('base64')}`
: undefined;
try {
const proxyAgent = new ProxyAgent({
uri:
(proxySettings.useSsl ? 'https://' : 'http://') +
proxySettings.hostname +
':' +
proxySettings.port,
token,
interceptors: {
Client: [noProxyInterceptor],
},
});
setGlobalDispatcher(proxyAgent);
} catch (e) {
logger.error('Failed to connect to the proxy: ' + e.message, {
label: 'Proxy',
});
setGlobalDispatcher(defaultAgent);
return;
}
try {
const res = await fetch('https://www.google.com', { method: 'HEAD' });
if (res.ok) {
logger.debug('HTTP(S) proxy connected successfully', { label: 'Proxy' });
} else {
logger.error('Proxy responded, but with a non-OK status: ' + res.status, {
label: 'Proxy',
});
setGlobalDispatcher(defaultAgent);
}
} catch (e) {
logger.error(
'Failed to connect to the proxy: ' + e.message + ': ' + e.cause,
{ label: 'Proxy' }
);
setGlobalDispatcher(defaultAgent);
}
}
function isLocalAddress(hostname: string) {
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return true;
}
const privateIpRanges = [
/^10\./, // 10.x.x.x
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.x.x - 172.31.x.x
/^192\.168\./, // 192.168.x.x
];
if (privateIpRanges.some((regex) => regex.test(hostname))) {
return true;
}
return false;
}

View File

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

View File

@@ -268,7 +268,6 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
{title && title.backdropPath && ( {title && title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3"> <div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage <CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`} src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
alt="" alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -294,7 +293,6 @@ 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" className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
> >
<CachedImage <CachedImage
type="tmdb"
src={ src={
title?.posterPath title?.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
@@ -357,7 +355,6 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
<Link href={`/users/${item.user.id}`}> <Link href={`/users/${item.user.id}`}>
<span className="group flex items-center truncate"> <span className="group flex items-center truncate">
<CachedImage <CachedImage
type="avatar"
src={item.user.avatar} src={item.user.avatar}
alt="" alt=""
className="avatar-sm ml-1.5" className="avatar-sm ml-1.5"

View File

@@ -198,7 +198,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
{data.backdropPath && ( {data.backdropPath && (
<div className="media-page-bg-image"> <div className="media-page-bg-image">
<CachedImage <CachedImage
type="tmdb"
alt="" alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`} src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -229,7 +228,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
<div className="media-header"> <div className="media-header">
<div className="media-poster"> <div className="media-poster">
<CachedImage <CachedImage
type="tmdb"
src={ src={
data.posterPath data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`

View File

@@ -4,31 +4,24 @@ import Image from 'next/image';
const imageLoader: ImageLoader = ({ src }) => src; const imageLoader: ImageLoader = ({ src }) => src;
export type CachedImageProps = ImageProps & {
src: string;
type: 'tmdb' | 'avatar';
};
/** /**
* The CachedImage component should be used wherever * The CachedImage component should be used wherever
* we want to offer the option to locally cache images. * we want to offer the option to locally cache images.
**/ **/
const CachedImage = ({ src, type, ...props }: CachedImageProps) => { const CachedImage = ({ src, ...props }: ImageProps) => {
const { currentSettings } = useSettings(); const { currentSettings } = useSettings();
let imageUrl: string; let imageUrl = src;
if (type === 'tmdb') { if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
// tmdb stuff const parsedUrl = new URL(imageUrl);
imageUrl =
currentSettings.cacheImages && !src.startsWith('/') if (parsedUrl.host === 'image.tmdb.org') {
? src.replace(/^https:\/\/image\.tmdb\.org\//, '/imageproxy/') if (currentSettings.cacheImages)
: src; imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy');
} else if (type === 'avatar') { } else if (parsedUrl.host !== 'gravatar.com') {
// jellyfin avatar (if any) imageUrl = '/avatarproxy/' + imageUrl;
imageUrl = src; }
} else {
return null;
} }
return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />; return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />;

View File

@@ -61,7 +61,6 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
{...props} {...props}
> >
<CachedImage <CachedImage
type="tmdb"
className="absolute inset-0 h-full w-full" className="absolute inset-0 h-full w-full"
alt="" alt=""
src={imageUrl} src={imageUrl}

View File

@@ -123,7 +123,6 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
{backdrop && ( {backdrop && (
<div className="absolute top-0 left-0 right-0 z-0 h-64 max-h-full w-full"> <div className="absolute top-0 left-0 right-0 z-0 h-64 max-h-full w-full">
<CachedImage <CachedImage
type="tmdb"
alt="" alt=""
src={backdrop} src={backdrop}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}

View File

@@ -33,7 +33,6 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => {
> >
<div className="relative h-full w-full"> <div className="relative h-full w-full">
<CachedImage <CachedImage
type="tmdb"
src={image} src={image}
alt={name} alt={name}
className="relative z-40 h-full w-full" className="relative z-40 h-full w-full"

View File

@@ -36,7 +36,6 @@ const GenreCard = ({ image, url, name, canExpand = false }: GenreCardProps) => {
tabIndex={0} tabIndex={0}
> >
<CachedImage <CachedImage
type="tmdb"
src={image} src={image}
alt="" alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}

View File

@@ -89,8 +89,7 @@ const IssueComment = ({
</Transition> </Transition>
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}> <Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
<CachedImage <CachedImage
type="avatar" src={`${comment.user.avatar}`}
src={comment.user.avatar}
alt="" 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" 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} width={40}

View File

@@ -217,7 +217,6 @@ const IssueDetails = () => {
{data.backdropPath && ( {data.backdropPath && (
<div className="media-page-bg-image"> <div className="media-page-bg-image">
<CachedImage <CachedImage
type="tmdb"
alt="" alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`} src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -236,7 +235,6 @@ const IssueDetails = () => {
<div className="media-header"> <div className="media-header">
<div className="media-poster"> <div className="media-poster">
<CachedImage <CachedImage
type="tmdb"
src={ src={
data.posterPath data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
@@ -289,8 +287,7 @@ const IssueDetails = () => {
className="group ml-1 inline-flex h-full items-center xl:ml-1.5" className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
> >
<CachedImage <CachedImage
type="avatar" src={`${issueData.createdBy.avatar}`}
src={issueData.createdBy.avatar}
alt="" 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" 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} width={20}

View File

@@ -112,7 +112,6 @@ const IssueItem = ({ issue }: IssueItemProps) => {
{title.backdropPath && ( {title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3"> <div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage <CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`} src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
alt="" alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -138,7 +137,6 @@ 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" className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
> >
<CachedImage <CachedImage
type="tmdb"
src={ src={
title.posterPath title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
@@ -228,8 +226,7 @@ const IssueItem = ({ issue }: IssueItemProps) => {
className="group flex items-center truncate" className="group flex items-center truncate"
> >
<CachedImage <CachedImage
type="avatar" src={'/avatarproxy/' + issue.createdBy.avatar}
src={issue.createdBy.avatar}
alt="" alt=""
className="avatar-sm ml-1.5 object-cover" className="avatar-sm ml-1.5 object-cover"
width={20} width={20}

View File

@@ -7,7 +7,6 @@ import {
CogIcon, CogIcon,
EllipsisHorizontalIcon, EllipsisHorizontalIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
EyeSlashIcon,
FilmIcon, FilmIcon,
SparklesIcon, SparklesIcon,
TvIcon, TvIcon,
@@ -17,7 +16,6 @@ import {
ClockIcon as FilledClockIcon, ClockIcon as FilledClockIcon,
CogIcon as FilledCogIcon, CogIcon as FilledCogIcon,
ExclamationTriangleIcon as FilledExclamationTriangleIcon, ExclamationTriangleIcon as FilledExclamationTriangleIcon,
EyeSlashIcon as FilledEyeSlashIcon,
FilmIcon as FilledFilmIcon, FilmIcon as FilledFilmIcon,
SparklesIcon as FilledSparklesIcon, SparklesIcon as FilledSparklesIcon,
TvIcon as FilledTvIcon, TvIcon as FilledTvIcon,
@@ -86,18 +84,6 @@ const MobileMenu = () => {
svgIconSelected: <FilledClockIcon className="h-6 w-6" />, svgIconSelected: <FilledClockIcon className="h-6 w-6" />,
activeRegExp: /^\/requests/, 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', href: '/issues',
content: intl.formatMessage(menuMessages.issues), content: intl.formatMessage(menuMessages.issues),

View File

@@ -57,7 +57,6 @@ const UserDropdown = () => {
data-testid="user-menu" data-testid="user-menu"
> >
<CachedImage <CachedImage
type="avatar"
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10" className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user ? user.avatar : ''} src={user ? user.avatar : ''}
alt="" alt=""
@@ -81,7 +80,6 @@ const UserDropdown = () => {
<div className="flex flex-col space-y-4 px-4 py-4"> <div className="flex flex-col space-y-4 px-4 py-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<CachedImage <CachedImage
type="avatar"
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10" className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user ? user.avatar : ''} src={user ? user.avatar : ''}
alt="" alt=""

View File

@@ -369,7 +369,6 @@ const ManageSlideOver = ({
content={user.displayName} content={user.displayName}
> >
<CachedImage <CachedImage
type="avatar"
src={user.avatar} src={user.avatar}
alt={user.displayName} 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" className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
@@ -531,7 +530,6 @@ const ManageSlideOver = ({
content={user.displayName} content={user.displayName}
> >
<CachedImage <CachedImage
type="avatar"
src={user.avatar} src={user.avatar}
alt={user.displayName} 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" className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"

View File

@@ -448,7 +448,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
{data.backdropPath && ( {data.backdropPath && (
<div className="media-page-bg-image"> <div className="media-page-bg-image">
<CachedImage <CachedImage
type="tmdb"
alt="" alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`} src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -495,7 +494,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
<div className="media-header"> <div className="media-header">
<div className="media-poster"> <div className="media-poster">
<CachedImage <CachedImage
type="tmdb"
src={ src={
data.posterPath data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
@@ -743,7 +741,6 @@ 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="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"> <div className="absolute inset-0 z-0">
<CachedImage <CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath}`} src={`https://image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath}`}
alt="" alt=""
style={{ style={{

View File

@@ -51,7 +51,6 @@ const PersonCard = ({
{profilePath ? ( {profilePath ? (
<div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700"> <div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700">
<CachedImage <CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`} src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
alt="" alt=""
style={{ style={{

View File

@@ -227,7 +227,6 @@ const PersonDetails = () => {
{data.profilePath && ( {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"> <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 <CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath}`} src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath}`}
alt="" alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}

View File

@@ -116,7 +116,6 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
> >
<span className="avatar-sm"> <span className="avatar-sm">
<CachedImage <CachedImage
type="avatar"
src={requestData.requestedBy.avatar} src={requestData.requestedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
@@ -346,7 +345,6 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
{title.backdropPath && ( {title.backdropPath && (
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
<CachedImage <CachedImage
type="tmdb"
alt="" alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`} src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -392,7 +390,6 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
> >
<span className="avatar-sm"> <span className="avatar-sm">
<CachedImage <CachedImage
type="avatar"
src={requestData.requestedBy.avatar} src={requestData.requestedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
@@ -605,7 +602,6 @@ 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" 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 <CachedImage
type="tmdb"
src={ src={
title.posterPath title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`

View File

@@ -42,7 +42,6 @@ const messages = defineMessages('components.RequestList.RequestItem', {
tmdbid: 'TMDB ID', tmdbid: 'TMDB ID',
tvdbid: 'TheTVDB ID', tvdbid: 'TheTVDB ID',
unknowntitle: 'Unknown Title', unknowntitle: 'Unknown Title',
removearr: 'Remove from {arr}',
profileName: 'Profile', profileName: 'Profile',
}); });
@@ -191,7 +190,6 @@ const RequestItemError = ({
> >
<span className="avatar-sm ml-1.5"> <span className="avatar-sm ml-1.5">
<CachedImage <CachedImage
type="avatar"
src={requestData.requestedBy.avatar} src={requestData.requestedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
@@ -251,7 +249,6 @@ const RequestItemError = ({
> >
<span className="avatar-sm ml-1.5"> <span className="avatar-sm ml-1.5">
<CachedImage <CachedImage
type="avatar"
src={requestData.modifiedBy.avatar} src={requestData.modifiedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
@@ -344,18 +341,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
revalidateList(); 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 () => { const retryRequest = async () => {
setRetrying(true); setRetrying(true);
@@ -420,7 +405,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
{title.backdropPath && ( {title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3"> <div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage <CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`} src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
alt="" alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -446,7 +430,6 @@ 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" className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
> >
<CachedImage <CachedImage
type="tmdb"
src={ src={
title.posterPath title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
@@ -574,7 +557,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
> >
<span className="avatar-sm ml-1.5"> <span className="avatar-sm ml-1.5">
<CachedImage <CachedImage
type="avatar"
src={requestData.requestedBy.avatar} src={requestData.requestedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
@@ -634,8 +616,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
> >
<span className="avatar-sm ml-1.5"> <span className="avatar-sm ml-1.5">
<CachedImage <CachedImage
type="avatar" src={requestData.requestedBy.avatar}
src={requestData.modifiedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
width={20} width={20}
@@ -685,28 +666,14 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
)} )}
{requestData.status !== MediaRequestStatus.PENDING && {requestData.status !== MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && ( hasPermission(Permission.MANAGE_REQUESTS) && (
<> <ConfirmButton
<ConfirmButton onClick={() => deleteRequest()}
onClick={() => deleteRequest()} confirmText={intl.formatMessage(globalMessages.areyousure)}
confirmText={intl.formatMessage(globalMessages.areyousure)} className="w-full"
className="w-full" >
> <TrashIcon />
<TrashIcon /> <span>{intl.formatMessage(messages.deleterequest)}</span>
<span>{intl.formatMessage(messages.deleterequest)}</span> </ConfirmButton>
</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 && {requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && ( hasPermission(Permission.MANAGE_REQUESTS) && (

View File

@@ -562,7 +562,6 @@ 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"> <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"> <span className="flex items-center">
<CachedImage <CachedImage
type="avatar"
src={selectedUser.avatar} src={selectedUser.avatar}
alt="" alt=""
className="h-6 w-6 flex-shrink-0 rounded-full object-cover" className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
@@ -615,7 +614,6 @@ const AdvancedRequester = ({
} flex items-center`} } flex items-center`}
> >
<CachedImage <CachedImage
type="avatar"
src={user.avatar} src={user.avatar}
alt="" alt=""
className="h-6 w-6 flex-shrink-0 rounded-full object-cover" className="h-6 w-6 flex-shrink-0 rounded-full object-cover"

View File

@@ -437,7 +437,6 @@ const CollectionRequestModal = ({
> >
<div className="relative h-auto w-10 flex-shrink-0 overflow-hidden rounded-md"> <div className="relative h-auto w-10 flex-shrink-0 overflow-hidden rounded-md">
<CachedImage <CachedImage
type="tmdb"
src={ src={
part.posterPath part.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${part.posterPath}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${part.posterPath}`

View File

@@ -452,7 +452,6 @@ export const WatchProviderSelector = ({
tabIndex={0} tabIndex={0}
> >
<CachedImage <CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`} src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt="" alt=""
style={{ style={{
@@ -498,7 +497,6 @@ export const WatchProviderSelector = ({
tabIndex={0} tabIndex={0}
> >
<CachedImage <CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`} src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt="" alt=""
style={{ style={{

View File

@@ -55,17 +55,6 @@ const messages = defineMessages('components.Settings.SettingsMain', {
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
partialRequestsEnabled: 'Allow Partial Series Requests', partialRequestsEnabled: 'Allow Partial Series Requests',
locale: 'Display Language', locale: 'Display Language',
proxyEnabled: 'HTTP(S) Proxy',
proxyHostname: 'Proxy Hostname',
proxyPort: 'Proxy Port',
proxySsl: 'Use SSL For Proxy',
proxyUser: 'Proxy Username',
proxyPassword: 'Proxy Password',
proxyBypassFilter: 'Proxy Ignored Addresses',
proxyBypassFilterTip:
"Use ',' as a separator, and '*.' as a wildcard for subdomains",
proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses',
validationProxyPort: 'You must provide a valid port',
}); });
const SettingsMain = () => { const SettingsMain = () => {
@@ -93,12 +82,6 @@ const SettingsMain = () => {
intl.formatMessage(messages.validationApplicationUrlTrailingSlash), intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
(value) => !value || !value.endsWith('/') (value) => !value || !value.endsWith('/')
), ),
proxyPort: Yup.number().when('proxyEnabled', {
is: (proxyEnabled: boolean) => proxyEnabled,
then: Yup.number().required(
intl.formatMessage(messages.validationProxyPort)
),
}),
}); });
const regenerate = async () => { const regenerate = async () => {
@@ -154,14 +137,6 @@ const SettingsMain = () => {
partialRequestsEnabled: data?.partialRequestsEnabled, partialRequestsEnabled: data?.partialRequestsEnabled,
trustProxy: data?.trustProxy, trustProxy: data?.trustProxy,
cacheImages: data?.cacheImages, cacheImages: data?.cacheImages,
proxyEnabled: data?.proxy?.enabled,
proxyHostname: data?.proxy?.hostname,
proxyPort: data?.proxy?.port,
proxySsl: data?.proxy?.useSsl,
proxyUser: data?.proxy?.user,
proxyPassword: data?.proxy?.password,
proxyBypassFilter: data?.proxy?.bypassFilter,
proxyBypassLocalAddresses: data?.proxy?.bypassLocalAddresses,
}} }}
enableReinitialize enableReinitialize
validationSchema={MainSettingsSchema} validationSchema={MainSettingsSchema}
@@ -183,16 +158,6 @@ const SettingsMain = () => {
partialRequestsEnabled: values.partialRequestsEnabled, partialRequestsEnabled: values.partialRequestsEnabled,
trustProxy: values.trustProxy, trustProxy: values.trustProxy,
cacheImages: values.cacheImages, cacheImages: values.cacheImages,
proxy: {
enabled: values.proxyEnabled,
hostname: values.proxyHostname,
port: values.proxyPort,
useSsl: values.proxySsl,
user: values.proxyUser,
password: values.proxyPassword,
bypassFilter: values.proxyBypassFilter,
bypassLocalAddresses: values.proxyBypassLocalAddresses,
},
}), }),
}); });
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
@@ -472,176 +437,6 @@ const SettingsMain = () => {
/> />
</div> </div>
</div> </div>
<div className="form-row">
<label htmlFor="proxyEnabled" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.proxyEnabled)}
</span>
<SettingsBadge badgeType="advanced" className="mr-2" />
<SettingsBadge badgeType="restartRequired" />
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="proxyEnabled"
name="proxyEnabled"
onChange={() => {
setFieldValue('proxyEnabled', !values.proxyEnabled);
}}
/>
</div>
</div>
{values.proxyEnabled && (
<>
<div className="form-row">
<label htmlFor="proxyHostname" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyHostname)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="proxyHostname"
name="proxyHostname"
type="text"
/>
</div>
{errors.proxyHostname &&
touched.proxyHostname &&
typeof errors.proxyHostname === 'string' && (
<div className="error">{errors.proxyHostname}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="proxyPort" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyPort)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="proxyPort" name="proxyPort" type="text" />
</div>
{errors.proxyPort &&
touched.proxyPort &&
typeof errors.proxyPort === 'string' && (
<div className="error">{errors.proxyPort}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="proxySsl" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxySsl)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="proxySsl"
name="proxySsl"
onChange={() => {
setFieldValue('proxySsl', !values.proxySsl);
}}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="proxyUser" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyUser)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="proxyUser" name="proxyUser" type="text" />
</div>
{errors.proxyUser &&
touched.proxyUser &&
typeof errors.proxyUser === 'string' && (
<div className="error">{errors.proxyUser}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="proxyPassword" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyPassword)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="proxyPassword"
name="proxyPassword"
type="password"
/>
</div>
{errors.proxyPassword &&
touched.proxyPassword &&
typeof errors.proxyPassword === 'string' && (
<div className="error">{errors.proxyPassword}</div>
)}
</div>
</div>
<div className="form-row">
<label
htmlFor="proxyBypassFilter"
className="checkbox-label"
>
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyBypassFilter)}
</span>
<span className="label-tip ml-4">
{intl.formatMessage(messages.proxyBypassFilterTip)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="proxyBypassFilter"
name="proxyBypassFilter"
type="text"
/>
</div>
{errors.proxyBypassFilter &&
touched.proxyBypassFilter &&
typeof errors.proxyBypassFilter === 'string' && (
<div className="error">
{errors.proxyBypassFilter}
</div>
)}
</div>
</div>
<div className="form-row">
<label
htmlFor="proxyBypassLocalAddresses"
className="checkbox-label"
>
<span className="mr-2 ml-4">
{intl.formatMessage(
messages.proxyBypassLocalAddresses
)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="proxyBypassLocalAddresses"
name="proxyBypassLocalAddresses"
onChange={() => {
setFieldValue(
'proxyBypassLocalAddresses',
!values.proxyBypassLocalAddresses
);
}}
/>
</div>
</div>
</>
)}
<div className="actions"> <div className="actions">
<div className="flex justify-end"> <div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm"> <span className="ml-3 inline-flex rounded-md shadow-sm">

View File

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

View File

@@ -346,7 +346,6 @@ const TitleCard = ({
> >
<div className="absolute inset-0 h-full w-full overflow-hidden"> <div className="absolute inset-0 h-full w-full overflow-hidden">
<CachedImage <CachedImage
type="tmdb"
className="absolute inset-0 h-full w-full" className="absolute inset-0 h-full w-full"
alt="" alt=""
src={ src={

View File

@@ -471,7 +471,6 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
{data.backdropPath && ( {data.backdropPath && (
<div className="media-page-bg-image"> <div className="media-page-bg-image">
<CachedImage <CachedImage
type="tmdb"
alt="" alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`} src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -528,7 +527,6 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
<div className="media-header"> <div className="media-header">
<div className="media-poster"> <div className="media-poster">
<CachedImage <CachedImage
type="tmdb"
src={ src={
data.posterPath data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`

View File

@@ -250,7 +250,6 @@ 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"> <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"> <div className="flex items-center">
<CachedImage <CachedImage
type="avatar"
className="h-10 w-10 flex-shrink-0 rounded-full" className="h-10 w-10 flex-shrink-0 rounded-full"
src={user.thumb} src={user.thumb}
alt="" alt=""

View File

@@ -634,7 +634,6 @@ const UserList = () => {
className="h-10 w-10 flex-shrink-0" className="h-10 w-10 flex-shrink-0"
> >
<CachedImage <CachedImage
type="avatar"
className="h-10 w-10 rounded-full object-cover" className="h-10 w-10 rounded-full object-cover"
src={user.avatar} src={user.avatar}
alt="" alt=""

View File

@@ -43,7 +43,6 @@ const ProfileHeader = ({ user, isSettingsPage }: ProfileHeaderProps) => {
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className="relative"> <div className="relative">
<CachedImage <CachedImage
type="avatar"
className="h-24 w-24 rounded-full bg-gray-600 object-cover ring-1 ring-gray-700" className="h-24 w-24 rounded-full bg-gray-600 object-cover ring-1 ring-gray-700"
src={user.avatar} src={user.avatar}
alt="" alt=""

View File

@@ -298,7 +298,6 @@
"components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {play} other {plays}}", "components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {play} other {plays}}",
"components.ManageSlideOver.removearr": "Remove from {arr}", "components.ManageSlideOver.removearr": "Remove from {arr}",
"components.ManageSlideOver.removearr4k": "Remove from 4K {arr}", "components.ManageSlideOver.removearr4k": "Remove from 4K {arr}",
"components.RequestList.RequestItem.removearr": "Remove from {arr}",
"components.ManageSlideOver.tvshow": "series", "components.ManageSlideOver.tvshow": "series",
"components.MediaSlider.ShowMoreCard.seemore": "See More", "components.MediaSlider.ShowMoreCard.seemore": "See More",
"components.MovieDetails.MovieCast.fullcast": "Full Cast", "components.MovieDetails.MovieCast.fullcast": "Full Cast",
@@ -1031,7 +1030,6 @@
"components.Settings.save": "Save Changes", "components.Settings.save": "Save Changes",
"components.Settings.saving": "Saving…", "components.Settings.saving": "Saving…",
"components.Settings.scan": "Sync Libraries", "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.scanning": "Syncing…",
"components.Settings.serverLocal": "local", "components.Settings.serverLocal": "local",
"components.Settings.serverRemote": "remote", "components.Settings.serverRemote": "remote",
@@ -1052,7 +1050,6 @@
"components.Settings.tautulliSettings": "Tautulli Settings", "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.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.timeout": "Timeout",
"components.Settings.tip": "Tip",
"components.Settings.toastPlexConnecting": "Attempting to connect to Plex…", "components.Settings.toastPlexConnecting": "Attempting to connect to Plex…",
"components.Settings.toastPlexConnectingFailure": "Failed to connect to Plex.", "components.Settings.toastPlexConnectingFailure": "Failed to connect to Plex.",
"components.Settings.toastPlexConnectingSuccess": "Plex connection established successfully!", "components.Settings.toastPlexConnectingSuccess": "Plex connection established successfully!",
@@ -1082,14 +1079,16 @@
"components.Setup.continue": "Continue", "components.Setup.continue": "Continue",
"components.Setup.finish": "Finish Setup", "components.Setup.finish": "Finish Setup",
"components.Setup.finishing": "Finishing…", "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.servertype": "Choose Server Type",
"components.Setup.setup": "Setup", "components.Setup.setup": "Setup",
"components.Setup.signin": "Sign in to your account", "components.Setup.signin": "Sign In",
"components.Setup.signinMessage": "Get started by signing in", "components.Setup.signinMessage": "Get started by signing in",
"components.Setup.signinWithEmby": "Enter your Emby details", "components.Setup.signinWithEmby": "Enter your Emby details",
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details", "components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
"components.Setup.signinWithPlex": "Enter your Plex details", "components.Setup.signinWithPlex": "Enter your Plex details",
"components.Setup.subtitle": "Get started by choosing your media server", "components.Setup.subtitle": "Get started by choosing your media server",
"components.Setup.tip": "Tip",
"components.Setup.welcome": "Welcome to Jellyseerr", "components.Setup.welcome": "Welcome to Jellyseerr",
"components.StatusBadge.managemedia": "Manage {mediaType}", "components.StatusBadge.managemedia": "Manage {mediaType}",
"components.StatusBadge.openinarr": "Open in {arr}", "components.StatusBadge.openinarr": "Open in {arr}",
@@ -1234,7 +1233,6 @@
"components.UserProfile.UserSettings.UserGeneralSettings.saving": "Saving…", "components.UserProfile.UserSettings.UserGeneralSettings.saving": "Saving…",
"components.UserProfile.UserSettings.UserGeneralSettings.seriesrequestlimit": "Series Request Limit", "components.UserProfile.UserSettings.UserGeneralSettings.seriesrequestlimit": "Series Request Limit",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailure": "Something went wrong while saving settings.", "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailure": "Something went wrong while saving settings.",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailureEmail": "This email is already taken!",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!", "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!",
"components.UserProfile.UserSettings.UserGeneralSettings.user": "User", "components.UserProfile.UserSettings.UserGeneralSettings.user": "User",
"components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID", "components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID",
@@ -1314,7 +1312,6 @@
"components.UserProfile.seriesrequest": "Series Requests", "components.UserProfile.seriesrequest": "Series Requests",
"components.UserProfile.totalrequests": "Total Requests", "components.UserProfile.totalrequests": "Total Requests",
"components.UserProfile.unlimited": "Unlimited", "components.UserProfile.unlimited": "Unlimited",
"i18n.addToBlacklist": "Add to Blacklist",
"i18n.advanced": "Advanced", "i18n.advanced": "Advanced",
"i18n.all": "All", "i18n.all": "All",
"i18n.approve": "Approve", "i18n.approve": "Approve",

View File

@@ -12,7 +12,6 @@ import { SettingsProvider } from '@app/context/SettingsContext';
import { UserContext } from '@app/context/UserContext'; import { UserContext } from '@app/context/UserContext';
import type { User } from '@app/hooks/useUser'; import type { User } from '@app/hooks/useUser';
import '@app/styles/globals.css'; import '@app/styles/globals.css';
import '@app/utils/fetchOverride';
import { polyfillIntl } from '@app/utils/polyfillIntl'; import { polyfillIntl } from '@app/utils/polyfillIntl';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces'; import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces';

View File

@@ -1,46 +0,0 @@
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 {};