Compare commits

...

26 Commits

Author SHA1 Message Date
Gauthier
9803bc40df refactor: switch from Fetch API to Axios 2024-11-07 13:16:36 +01:00
Gauthier
64f4610b9f fix: resolve error when setup on second attempt (#1061) 2024-11-06 15:21:19 +08:00
Ludovic Ortega
2d3b777daf docs: migrate to docker compose v2 (#1073)
Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2024-11-04 22:48:37 +08:00
Fallenbagel
cf59102ef9 fix(externalapi): extract basic auth and pass it through header (#1062)
This commit adds extraction of basic authentication credentials from the URL and then pass the
credentials as the `Authorization` header. And then credentials are removed from the URL before
being passed to fetch. This is done because fetch request cannot be constructed using a URL with
credentials

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

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

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

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

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

* fix: add error handling for proxy creating

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

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

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

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

* feat: add a proxy option into settings

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

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

* fix: avoid backup to be replaced at next startup

* fix: resolve review comments

* fix: try to fix CodeQL warnings
2024-10-24 18:12:42 +02:00
Gauthier
0bbcfcbd5e fix: cache Jellyfin/Emby avatars from API (#1045)
* fix: cache Jellyfin/Emby avatars from API

Previously, avatars were cached using image links from Jellyfin/Emby. Now, avatar images are
obtained directly from the API to avoid some configuration bugs.

* fix: update avatar on new login
2024-10-24 18:11:25 +02:00
Fallenbagel
32e0b129fe docs(aur): add disclaimer about being maintained by third-party (#1044) 2024-10-22 05:20:14 +08:00
Gauthier
a2b3408c9a feat: exit Jellyseerr when migration fails (#1026) 2024-10-18 18:24:29 +08:00
Fallenbagel
cbb1a74526 fix: fixes wrong avatar rendered for the modifiedBy user in request list (#1028)
This fixes an issue where when the request is modified it was showing the avatar of the requester
instead of the modifiedBy user

fix #1017
2024-10-18 06:28:42 +08:00
Fallenbagel
26c37ec067 docs(buildfromsource): remove latest/develop tabs and update instructions to support 2.0.0 (#1021)
re #1020
2024-10-17 23:12:41 +08:00
Gauthier
4e48fdf2cb fix: rewrite avatarproxy and CachedImage (#1016)
* fix: rewrite avatarproxy and CachedImage

Avatar proxy was allowing every request to be proxied, no matter the original ressource's origin or
filetype. This PR fixes it be allowing only relevant resources to be cached, i.e. Jellyfin/Emby
images and TMDB images.

fix #1012, #1013

* fix: resolve CodeQL error

* fix: resolve CodeQL error

* fix: resolve review comments

* fix: resolve review comment

* fix: resolve CodeQL error

* fix: update imageproxy path
2024-10-17 21:24:15 +08:00
Gauthier
a351264b87 fix: handle non-existent rottentomatoes rating (#1018)
This fixes a bug where some media don't have any rottentomatoes ratings.
2024-10-17 18:37:19 +08:00
allcontributors[bot]
9de304d17a docs: add M0NsTeRRR as a contributor for security (#1015)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-10-17 07:25:36 +08:00
Fallenbagel
4945b54298 fix: fetch override to attach XSRF token to fix csrfProtection issue (#1014)
During the migration from Axios to fetch, we overlooked the fact that Axios automatically handled
CSRF tokens, while fetch does not. When CSRF protection was turned on, requests were failing with an
"invalid CSRF token" error for users accessing the app even via HTTPS. This commit
overrides fetch to ensure that the CSRF token is included in all requests.

fix #1011
2024-10-17 07:25:06 +08:00
Fallenbagel
a0f80fe764 fix: use jellyfinMediaId4k for mediaUrl4k (#1006)
Fixes the issue where mediaUrl4K was still using the non-4k mediaId despite having the correct 4k Id
stored.

fix #520
2024-10-16 03:50:21 +08:00
Gauthier
92ba26207d feat: refresh monitored downloads before getting queue items (#994)
Currently, we sync with sonarr/radarr with whatever value those return. Radarr/Sonarr syncs the
activity from the download clients every few minutes. This leads to inaccurate estimated download
times, because of the refresh delay with Jellyseerr and the *arrs.

This PR fixes this by making a request to the *arrs to refresh the monitored downloads just before
we get these downloads information.

re #866
2024-10-10 11:37:08 +02:00
Gauthier
96e1d40304 fix(session): set the correct TTL for the cookie store (#992)
The time-to-live (TTL) of cookies stored in the database was incorrect because the connect-typeorm
library takes a TTL in seconds and not milliseconds, making cookies valid for ~82 years instead of
30 days.

fix #991
2024-10-02 20:59:35 +02:00
Thomas Loubiou
a5d22ba5b8 feat: allow request managers to delete data from sonarr/radarr (#644)
* feat: allow requests managers to delete media files

* fix(i18n): add missing translations

* fix(i18n): remove french translation

* refactor: use fetch API
2024-09-30 18:56:25 +02:00
Gauthier
f390da4866 fix(blacklist): add blacklist to mobile menu (#980)
* fix(blacklist): add blacklist to mobile menu

The "Blacklist" menu was only available in the desktop sidebar, not in the mobile menu.

fix #979

* fix: export translations
2024-09-25 21:25:44 +02:00
Joaquin Olivero
edfd80444c refactor: Proxy and cache avatar images (#907)
* refactor: proxy and cache user avatar images

* fix: extract keys

* fix: set avatar image URL

* fix: show the correct avatar in the list of available users in advanced request

* fix(s): set correct src URL for cached image

* fix: remove unexpired unused image when a user changes their avatar

* fix: requested changes

* refactor: use 'mime' package to detmerine file extension

* style: grammar

* refactor: checks if the default avatar is cached to avoid creating duplicates for different users

* fix: fix vulnerability

* fix: fix incomplete URL substring sanitization

* refactor: only cache avatar with http url protocol

* fix: remove log and correctly set the if statement for the cached image component

* fix: avatar images not showing on issues page

* style: formatting

---------

Co-authored-by: JoaquinOlivero <joaquin.olivero@hotmail.com>
2024-09-19 10:38:14 +08:00
Gauthier
2b05ffface chore(issuetemplate): update defaults labels of GitHub issues (#968) 2024-09-17 11:12:00 +05:00
150 changed files with 6552 additions and 7276 deletions

View File

@@ -439,6 +439,15 @@
"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*
docker-compose.yml compose.yaml
docs docs
LICENSE LICENSE
node_modules node_modules

2
.gitattributes vendored
View File

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

View File

@@ -1,6 +1,6 @@
name: 🐛 Bug Report name: 🐛 Bug Report
description: Report a problem description: Report a problem
labels: ['type:bug', 'awaiting-triage'] labels: ['bug', 'awaiting triage']
body: body:
- type: markdown - type: markdown
attributes: attributes:

View File

@@ -1,6 +1,6 @@
name: ✨ Feature Request name: ✨ Feature Request
description: Suggest an idea description: Suggest an idea
labels: ['type:enhancement', 'awaiting-triage'] labels: ['enhancement', 'awaiting triage']
body: body:
- type: markdown - type: markdown
attributes: attributes:

1
.gitignore vendored
View File

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

View File

@@ -8,4 +8,3 @@ pnpm-lock.yaml
# assets # assets
src/assets/ src/assets/
public/ public/
docs/

View File

@@ -3,12 +3,6 @@ module.exports = {
singleQuote: true, singleQuote: true,
trailingComma: 'es5', trailingComma: 'es5',
overrides: [ overrides: [
{
files: 'pnpm-lock.yaml',
options: {
rangeEnd: 0, // default: Infinity
},
},
{ {
files: 'gen-docs/pnpm-lock.yaml', files: 'gen-docs/pnpm-lock.yaml',
options: { options: {

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-47-orange.svg"/></a> <a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-48-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- 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,6 +146,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
<td align="center" valign="top" width="14.28%"><a href="https://github.com/XDark187"><img src="https://avatars.githubusercontent.com/u/39034192?v=4?s=100" width="100px;" alt="Baraa"/><br /><sub><b>Baraa</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=XDark187" title="Code">💻</a></td> <td align="center" valign="top" width="14.28%"><a href="https://github.com/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,4 +1,3 @@
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 `docker-compose.yml` file: Add the following labels to the Jellyseerr service in your `compose.yaml` file:
```yaml ```yaml
labels: labels:

View File

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

View File

@@ -3,7 +3,9 @@ title: Build From Source (Advanced)
description: Install Jellyseerr by building from source description: Install Jellyseerr by building from source
sidebar_position: 2 sidebar_position: 2
--- ---
# Build from Source (Advanced) # Build from Source (Advanced)
:::warning :::warning
This method is not recommended for most users. It is intended for advanced users who are familiar with managing their own server infrastructure. This method is not recommended for most users. It is intended for advanced users who are familiar with managing their own server infrastructure.
::: :::
@@ -12,79 +14,53 @@ 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
``` ```
2. Clone the Jellyseerr repository and checkout the develop branch: 2. Clone the Jellyseerr repository and checkout the develop branch:
```bash ```bash
git clone https://github.com/Fallenbagel/jellyseerr.git git clone https://github.com/Fallenbagel/jellyseerr.git
cd jellyseerr cd jellyseerr
git checkout develop # by default, you are on the develop branch so this step is not necessary git checkout develop # by default, you are on the develop branch so this step is not necessary
``` ```
3. Install the dependencies: 3. Install the dependencies:
```bash ```bash
CYPRESS_INSTALL_BINARY=0 pnpm install --frozen-lockfile CYPRESS_INSTALL_BINARY=0 pnpm install --frozen-lockfile
``` ```
4. Build the project: 4. Build the project:
```bash ```bash
pnpm build pnpm build
``` ```
5. Start Jellyseerr: 5. Start Jellyseerr:
```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.
::: :::
#### Extending the installation #### Extending the installation
<Tabs groupId="unix-extensions" queryString> <Tabs groupId="unix-extensions" queryString>
<TabItem value="linux" label="Linux"> <TabItem value="linux" label="Linux">
To run jellyseerr as a systemd service: To run jellyseerr as a systemd service:
@@ -95,21 +71,23 @@ To run jellyseerr as a systemd service:
PORT=5055 PORT=5055
## specify on which interface to listen, by default jellyseerr listens on all interfaces ## specify on which interface to listen, by default jellyseerr listens on all interfaces
#HOST=127.0.0.1 #HOST=127.0.0.1
## Uncomment if your media server is emby instead of jellyfin. ## Uncomment if your media server is emby instead of jellyfin.
# JELLYFIN_TYPE=emby # JELLYFIN_TYPE=emby
## Uncomment if you want to force Node.js to resolve IPv4 before IPv6 (advanced users only) ````
# FORCE_IPV4_FIRST=true
```
2. Then run the following commands: 2. Then run the following commands:
```bash ```bash
which node which node
``` ````
Copy the path to node, it should be something like `/usr/bin/node`. Copy the path to node, it should be something like `/usr/bin/node`.
3. Create the systemd service file at `/etc/systemd/system/jellyseerr.service`, using either `sudo systemctl edit jellyseerr` or `sudo nano /etc/systemd/system/jellyseerr.service`: 3. Create the systemd service file at `/etc/systemd/system/jellyseerr.service`, using either `sudo systemctl edit jellyseerr` or `sudo nano /etc/systemd/system/jellyseerr.service`:
```bash ```bash
[Unit] [Unit]
Description=Jellyseerr Service Description=Jellyseerr Service
@@ -127,15 +105,18 @@ ExecStart=/usr/bin/node dist/index.js
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
``` ```
:::note :::note
If you are using a different path to node, replace `/usr/bin/node` with the path to node. If you are using a different path to node, replace `/usr/bin/node` with the path to node.
::: :::
4. Enable and start the service: 4. Enable and start the service:
```bash ```bash
sudo systemctl enable jellyseerr sudo systemctl enable jellyseerr
sudo systemctl start jellyseerr sudo systemctl start jellyseerr
``` ```
</TabItem> </TabItem>
<TabItem value="macos" label="macOS"> <TabItem value="macos" label="macOS">
To run jellyseerr as a launchd service: To run jellyseerr as a launchd service:
@@ -146,6 +127,7 @@ which node
Copy the path to node, it should be something like `/usr/local/bin/node`. Copy the path to node, it should be something like `/usr/local/bin/node`.
2. Create a launchd plist file at `~/Library/LaunchAgents/com.jellyseerr.plist`: 2. Create a launchd plist file at `~/Library/LaunchAgents/com.jellyseerr.plist`:
```xml ```xml
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -174,21 +156,27 @@ Copy the path to node, it should be something like `/usr/local/bin/node`.
</dict> </dict>
</plist> </plist>
``` ```
:::note :::note
If you are using a different path to node, replace `/usr/local/bin/node` with the path to node. If you are using a different path to node, replace `/usr/local/bin/node` with the path to node.
::: ::: 3. Load the service:
3. Load the service:
```bash ```bash
sudo launchctl load ~/Library/LaunchAgents/com.jellyseerr.plist sudo launchctl load ~/Library/LaunchAgents/com.jellyseerr.plist
``` ```
3. Start the service: 3. Start the service:
```bash ```bash
sudo launchctl start com.jellyseerr sudo launchctl start com.jellyseerr
``` ```
4. To ensure the service starts on boot, run the following command: 4. To ensure the service starts on boot, run the following command:
```bash ```bash
sudo lauchctl load sudo lauchctl load
``` ```
</TabItem> </TabItem>
<TabItem value="pm2" label="PM2"> <TabItem value="pm2" label="PM2">
To run jellyseerr as a PM2 service: To run jellyseerr as a PM2 service:
@@ -233,59 +221,41 @@ pm2 status jellyseerr
</Tabs> </Tabs>
## 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
cd C:\jellyseerr cd C:\jellyseerr
``` ```
2. Clone the Jellyseerr repository and checkout the develop branch: 2. Clone the Jellyseerr repository and checkout the develop branch:
```powershell ```powershell
git clone https://github.com/Fallenbagel/jellyseerr.git . git clone https://github.com/Fallenbagel/jellyseerr.git .
git checkout develop # by default, you are on the develop branch so this step is not necessary git checkout develop # by default, you are on the develop branch so this step is not necessary
``` ```
3. Install the dependencies: 3. Install the dependencies:
```powershell ```powershell
npm install -g win-node-env npm install -g win-node-env
set CYPRESS_INSTALL_BINARY=0 && pnpm install --frozen-lockfile set CYPRESS_INSTALL_BINARY=0 && pnpm install --frozen-lockfile
``` ```
4. Build the project: 4. Build the project:
```powershell ```powershell
pnpm build pnpm build
``` ```
5. Start Jellyseerr: 5. Start Jellyseerr:
```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.
@@ -296,6 +266,7 @@ You can now access Jellyseerr by visiting `http://localhost:5055` in your web br
::: :::
#### Extending the installation #### Extending the installation
<Tabs groupId="windows-extensions" queryString> <Tabs groupId="windows-extensions" queryString>
<TabItem value="task-scheduler" label="Task Scheduler"> <TabItem value="task-scheduler" label="Task Scheduler">
To run jellyseerr as a bat script: To run jellyseerr as a bat script:
@@ -313,9 +284,11 @@ 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.
</TabItem> </TabItem>
<TabItem value="nssm" label="NSSM"> <TabItem value="nssm" label="NSSM">
@@ -378,9 +351,11 @@ pm2 status jellyseerr
</Tabs> </Tabs>
### Updating ### Updating
To update Jellyseerr, navigate to the Jellyseerr directory and run the following commands: To update Jellyseerr, navigate to the Jellyseerr directory and run the following commands:
```bash ```bash
git pull git pull
``` ```
Then, follow the steps in the installation section to rebuild and restart Jellyseerr.
Then, follow the steps in the installation section to rebuild and restart Jellyseerr.

View File

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

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/basic-features/typescript for more information. // see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.

View File

@@ -4,13 +4,11 @@
module.exports = { module.exports = {
env: { env: {
commitTag: process.env.COMMIT_TAG || 'local', commitTag: process.env.COMMIT_TAG || 'local',
forceIpv4First: process.env.FORCE_IPV4_FIRST === 'true' ? 'true' : 'false',
}, },
images: { images: {
remotePatterns: [ remotePatterns: [
{ hostname: 'gravatar.com' }, { hostname: 'gravatar.com' },
{ hostname: 'image.tmdb.org' }, { hostname: 'image.tmdb.org' },
{ hostname: '*', protocol: 'https' },
], ],
}, },
webpack(config) { webpack(config) {

View File

@@ -1988,6 +1988,9 @@ paths:
appDataPath: appDataPath:
type: string type: string
example: /app/config example: /app/config
appDataPermissions:
type: boolean
example: true
/settings/main: /settings/main:
get: get:
summary: Get main settings summary: Get main settings
@@ -2790,6 +2793,15 @@ paths:
imageCount: imageCount:
type: number type: number
example: 123 example: 123
avatar:
type: object
properties:
size:
type: number
example: 123456
imageCount:
type: number
example: 123
apiCaches: apiCaches:
type: array type: array
items: items:

View File

@@ -43,6 +43,8 @@
"@svgr/webpack": "6.5.1", "@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.30", "@tanem/react-nprogress": "5.0.30",
"ace-builds": "1.15.2", "ace-builds": "1.15.2",
"axios": "1.3.4",
"axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0", "bcrypt": "5.1.0",
"bowser": "2.11.0", "bowser": "2.11.0",
"connect-typeorm": "1.1.4", "connect-typeorm": "1.1.4",
@@ -60,8 +62,10 @@
"express-rate-limit": "6.7.0", "express-rate-limit": "6.7.0",
"express-session": "1.17.3", "express-session": "1.17.3",
"formik": "^2.4.6", "formik": "^2.4.6",
"global-agent": "^3.0.0",
"gravatar-url": "3.1.0", "gravatar-url": "3.1.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"mime": "3",
"next": "^14.2.4", "next": "^14.2.4",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"node-gyp": "9.3.1", "node-gyp": "9.3.1",
@@ -92,7 +96,8 @@
"sqlite3": "5.1.4", "sqlite3": "5.1.4",
"swagger-ui-express": "4.6.2", "swagger-ui-express": "4.6.2",
"swr": "2.2.5", "swr": "2.2.5",
"typeorm": "0.3.12", "typeorm": "0.3.11",
"undici": "^6.20.1",
"web-push": "3.5.0", "web-push": "3.5.0",
"winston": "3.8.2", "winston": "3.8.2",
"winston-daily-rotate-file": "4.7.1", "winston-daily-rotate-file": "4.7.1",
@@ -119,6 +124,7 @@
"@types/express": "4.17.17", "@types/express": "4.17.17",
"@types/express-session": "1.17.6", "@types/express-session": "1.17.6",
"@types/lodash": "4.14.191", "@types/lodash": "4.14.191",
"@types/mime": "3",
"@types/node": "20.14.8", "@types/node": "20.14.8",
"@types/node-schedule": "2.1.0", "@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7", "@types/nodemailer": "6.4.7",

8206
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,7 @@
import logger from '@server/logger'; import logger from '@server/logger';
import fs, { promises as fsp } from 'node:fs'; import axios from 'axios';
import path from 'node:path'; import fs, { promises as fsp } from 'fs';
import { Readable } from 'node:stream'; import path from 'path';
import type { ReadableStream } from 'node:stream/web';
import xml2js from 'xml2js'; import xml2js from 'xml2js';
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
@@ -162,18 +161,13 @@ class AnimeListMapping {
label: 'Anime-List Sync', label: 'Anime-List Sync',
}); });
try { try {
const response = await fetch(MAPPING_URL); const response = await axios.get(MAPPING_URL, {
if (!response.ok) { responseType: 'stream',
throw new Error(`Failed to fetch: ${response.statusText}`); });
} await new Promise<void>((resolve) => {
await new Promise<void>((resolve, reject) => {
const writer = fs.createWriteStream(LOCAL_PATH); const writer = fs.createWriteStream(LOCAL_PATH);
writer.on('finish', resolve); writer.on('finish', resolve);
writer.on('error', reject); response.data.pipe(writer);
if (!response.body) return reject();
Readable.fromWeb(response.body as ReadableStream<Uint8Array>).pipe(
writer
);
}); });
} catch (e) { } catch (e) {
throw new Error(`Failed to download Anime-List mapping: ${e.message}`); throw new Error(`Failed to download Anime-List mapping: ${e.message}`);

View File

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

View File

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

View File

@@ -109,6 +109,8 @@ class JellyfinAPI extends ExternalAPI {
{ {
headers: { headers: {
'X-Emby-Authorization': authHeaderVal, 'X-Emby-Authorization': authHeaderVal,
'Content-Type': 'application/json',
Accept: 'application/json',
}, },
} }
); );
@@ -120,7 +122,7 @@ class JellyfinAPI extends ExternalAPI {
ClientIP?: string ClientIP?: string
): Promise<JellyfinLoginResponse> { ): Promise<JellyfinLoginResponse> {
const authenticate = async (useHeaders: boolean) => { const authenticate = async (useHeaders: boolean) => {
const headers: { [key: string]: string } = const headers =
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {}; useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
return this.post<JellyfinLoginResponse>( return this.post<JellyfinLoginResponse>(
@@ -129,8 +131,6 @@ class JellyfinAPI extends ExternalAPI {
Username, Username,
Pw: Password, Pw: Password,
}, },
{},
undefined,
{ headers } { headers }
); );
}; };
@@ -291,16 +291,7 @@ class JellyfinAPI extends ExternalAPI {
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> { public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
try { try {
const libraryItemsResponse = await this.get<any>( const libraryItemsResponse = await this.get<any>(
`/Users/${this.userId}/Items`, `/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
{
SortBy: 'SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Series,Movie,Others',
Recursive: 'true',
StartIndex: '0',
ParentId: id,
collapseBoxSetItems: 'false',
}
); );
return libraryItemsResponse.Items.filter( return libraryItemsResponse.Items.filter(
@@ -319,11 +310,7 @@ class JellyfinAPI extends ExternalAPI {
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> { public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
try { try {
const itemResponse = await this.get<any>( const itemResponse = await this.get<any>(
`/Users/${this.userId}/Items/Latest`, `/Users/${this.userId}/Items/Latest?Limit=12&ParentId=${id}`
{
Limit: '12',
ParentId: id,
}
); );
return itemResponse; return itemResponse;
@@ -382,10 +369,7 @@ class JellyfinAPI extends ExternalAPI {
): Promise<JellyfinLibraryItem[]> { ): Promise<JellyfinLibraryItem[]> {
try { try {
const episodeResponse = await this.get<any>( const episodeResponse = await this.get<any>(
`/Shows/${seriesID}/Episodes`, `/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
{
seasonId: seasonID,
}
); );
return episodeResponse.Items.filter( return episodeResponse.Items.filter(
@@ -410,7 +394,7 @@ class JellyfinAPI extends ExternalAPI {
).AccessToken; ).AccessToken;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while creating an API key the Jellyfin server: ${e.message}`, `Something went wrong while creating an API key from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );

View File

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

View File

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

View File

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

View File

@@ -155,13 +155,13 @@ export interface IMDBRating {
*/ */
class IMDBRadarrProxy extends ExternalAPI { class IMDBRadarrProxy extends ExternalAPI {
constructor() { constructor() {
super( super('https://api.radarr.video/v1', {
'https://api.radarr.video/v1', headers: {
{}, 'Content-Type': 'application/json',
{ Accept: 'application/json',
nodeCache: cacheManager.getCache('imdb').data, },
} nodeCache: cacheManager.getCache('imdb').data,
); });
} }
/** /**

View File

@@ -63,12 +63,15 @@ class RottenTomatoes extends ExternalAPI {
super( super(
'https://79frdp12pn-dsn.algolia.net/1/indexes/*', 'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
{ {
'x-algolia-agent': 'Algolia for JavaScript (4.14.3); Browser (lite)', 'x-algolia-agent':
'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)',
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561', 'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
'x-algolia-application-id': '79FRDP12PN', 'x-algolia-application-id': '79FRDP12PN',
}, },
{ {
headers: { headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-algolia-usertoken': settings.clientId, 'x-algolia-usertoken': settings.clientId,
}, },
nodeCache: cacheManager.getCache('rt').data, nodeCache: cacheManager.getCache('rt').data,
@@ -182,7 +185,7 @@ class RottenTomatoes extends ExternalAPI {
); );
} }
if (!tvshow) { if (!tvshow || !tvshow.rottenTomatoes) {
return null; return null;
} }

View File

@@ -113,9 +113,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getSystemStatus = async (): Promise<SystemStatus> => { public getSystemStatus = async (): Promise<SystemStatus> => {
try { try {
const data = await this.get<SystemStatus>('/system/status'); const response = await this.axios.get<SystemStatus>('/system/status');
return data; return response.data;
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`[${this.apiName}] Failed to retrieve system status: ${e.message}` `[${this.apiName}] Failed to retrieve system status: ${e.message}`
@@ -157,11 +157,16 @@ 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>>(`/queue`, { const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
includeEpisode: 'true', `/queue`,
}); {
params: {
includeEpisode: true,
},
}
);
return data.records; return response.data.records;
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`[${this.apiName}] Failed to retrieve queue: ${e.message}` `[${this.apiName}] Failed to retrieve queue: ${e.message}`
@@ -171,9 +176,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getTags = async (): Promise<Tag[]> => { public getTags = async (): Promise<Tag[]> => {
try { try {
const data = await this.get<Tag[]>(`/tag`); const response = await this.axios.get<Tag[]>(`/tag`);
return data; return response.data;
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`[${this.apiName}] Failed to retrieve tags: ${e.message}` `[${this.apiName}] Failed to retrieve tags: ${e.message}`
@@ -183,22 +188,26 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public createTag = async ({ label }: { label: string }): Promise<Tag> => { public createTag = async ({ label }: { label: string }): Promise<Tag> => {
try { try {
const data = await this.post<Tag>(`/tag`, { const response = await this.axios.post<Tag>(`/tag`, {
label, label,
}); });
return data; return response.data;
} catch (e) { } catch (e) {
throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`); throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`);
} }
}; };
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(`/command`, { await this.axios.post(`/command`, {
name: commandName, name: commandName,
...options, ...options,
}); });

View File

@@ -37,9 +37,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public getMovies = async (): Promise<RadarrMovie[]> => { public getMovies = async (): Promise<RadarrMovie[]> => {
try { try {
const data = await this.get<RadarrMovie[]>('/movie'); const response = await this.axios.get<RadarrMovie[]>('/movie');
return data; return response.data;
} catch (e) { } catch (e) {
throw new Error(`[Radarr] Failed to retrieve movies: ${e.message}`); throw new Error(`[Radarr] Failed to retrieve movies: ${e.message}`);
} }
@@ -47,9 +47,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public getMovie = async ({ id }: { id: number }): Promise<RadarrMovie> => { public getMovie = async ({ id }: { id: number }): Promise<RadarrMovie> => {
try { try {
const data = await this.get<RadarrMovie>(`/movie/${id}`); const response = await this.axios.get<RadarrMovie>(`/movie/${id}`);
return data; return response.data;
} catch (e) { } catch (e) {
throw new Error(`[Radarr] Failed to retrieve movie: ${e.message}`); throw new Error(`[Radarr] Failed to retrieve movie: ${e.message}`);
} }
@@ -57,15 +57,17 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> { public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
try { try {
const data = await this.get<RadarrMovie[]>('/movie/lookup', { const response = await this.axios.get<RadarrMovie[]>('/movie/lookup', {
term: `tmdb:${id}`, params: {
term: `tmdb:${id}`,
},
}); });
if (!data[0]) { if (!response.data[0]) {
throw new Error('Movie not found'); throw new Error('Movie not found');
} }
return data[0]; return response.data[0];
} catch (e) { } catch (e) {
logger.error('Error retrieving movie by TMDB ID', { logger.error('Error retrieving movie by TMDB ID', {
label: 'Radarr API', label: 'Radarr API',
@@ -95,7 +97,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
// movie exists in Radarr but is neither downloaded nor monitored // movie exists in Radarr but is neither downloaded nor monitored
if (movie.id && !movie.monitored) { if (movie.id && !movie.monitored) {
const data = await this.put<RadarrMovie>(`/movie`, { const response = await this.axios.put<RadarrMovie>(`/movie`, {
...movie, ...movie,
title: options.title, title: options.title,
qualityProfileId: options.qualityProfileId, qualityProfileId: options.qualityProfileId,
@@ -112,25 +114,25 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
}, },
}); });
if (data.monitored) { if (response.data.monitored) {
logger.info( logger.info(
'Found existing title in Radarr and set it to monitored.', 'Found existing title in Radarr and set it to monitored.',
{ {
label: 'Radarr', label: 'Radarr',
movieId: data.id, movieId: response.data.id,
movieTitle: data.title, movieTitle: response.data.title,
} }
); );
logger.debug('Radarr update details', { logger.debug('Radarr update details', {
label: 'Radarr', label: 'Radarr',
movie: data, movie: response.data,
}); });
if (options.searchNow) { if (options.searchNow) {
this.searchMovie(data.id); this.searchMovie(response.data.id);
} }
return data; return response.data;
} else { } else {
logger.error('Failed to update existing movie in Radarr.', { logger.error('Failed to update existing movie in Radarr.', {
label: 'Radarr', label: 'Radarr',
@@ -148,7 +150,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
return movie; return movie;
} }
const data = await this.post<RadarrMovie>(`/movie`, { const response = await this.axios.post<RadarrMovie>(`/movie`, {
title: options.title, title: options.title,
qualityProfileId: options.qualityProfileId, qualityProfileId: options.qualityProfileId,
profileId: options.profileId, profileId: options.profileId,
@@ -164,11 +166,11 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
}, },
}); });
if (data.id) { if (response.data.id) {
logger.info('Radarr accepted request', { label: 'Radarr' }); logger.info('Radarr accepted request', { label: 'Radarr' });
logger.debug('Radarr add details', { logger.debug('Radarr add details', {
label: 'Radarr', label: 'Radarr',
movie: data, movie: response.data,
}); });
} else { } else {
logger.error('Failed to add movie to Radarr', { logger.error('Failed to add movie to Radarr', {
@@ -177,7 +179,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
}); });
throw new Error('Failed to add movie to Radarr'); throw new Error('Failed to add movie to Radarr');
} }
return data; return response.data;
} catch (e) { } catch (e) {
let errorData; let errorData;
try { try {
@@ -221,9 +223,11 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public removeMovie = async (movieId: number): Promise<void> => { public removeMovie = async (movieId: number): Promise<void> => {
try { try {
const { id, title } = await this.getMovieByTmdbId(movieId); const { id, title } = await this.getMovieByTmdbId(movieId);
await this.delete(`/movie/${id}`, { await this.axios.delete(`/movie/${id}`, {
deleteFiles: 'true', params: {
addImportExclusion: 'false', deleteFiles: true,
addImportExclusion: false,
},
}); });
logger.info(`[Radarr] Removed movie ${title}`); logger.info(`[Radarr] Removed movie ${title}`);
} catch (e) { } catch (e) {

View File

@@ -117,9 +117,9 @@ class SonarrAPI extends ServarrBase<{
public async getSeries(): Promise<SonarrSeries[]> { public async getSeries(): Promise<SonarrSeries[]> {
try { try {
const data = await this.get<SonarrSeries[]>('/series'); const response = await this.axios.get<SonarrSeries[]>('/series');
return data; return response.data;
} catch (e) { } catch (e) {
throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`); throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`);
} }
@@ -127,9 +127,9 @@ class SonarrAPI extends ServarrBase<{
public async getSeriesById(id: number): Promise<SonarrSeries> { public async getSeriesById(id: number): Promise<SonarrSeries> {
try { try {
const data = await this.get<SonarrSeries>(`/series/${id}`); const response = await this.axios.get<SonarrSeries>(`/series/${id}`);
return data; return response.data;
} catch (e) { } catch (e) {
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`); throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
} }
@@ -137,15 +137,17 @@ class SonarrAPI extends ServarrBase<{
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> { public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
try { try {
const data = await this.get<SonarrSeries[]>('/series/lookup', { const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
term: title, params: {
term: title,
},
}); });
if (!data[0]) { if (!response.data[0]) {
throw new Error('No series found'); throw new Error('No series found');
} }
return data; return response.data;
} catch (e) { } catch (e) {
logger.error('Error retrieving series by series title', { logger.error('Error retrieving series by series title', {
label: 'Sonarr API', label: 'Sonarr API',
@@ -158,15 +160,17 @@ class SonarrAPI extends ServarrBase<{
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> { public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
try { try {
const data = await this.get<SonarrSeries[]>('/series/lookup', { const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
term: `tvdb:${id}`, params: {
term: `tvdb:${id}`,
},
}); });
if (!data[0]) { if (!response.data[0]) {
throw new Error('Series not found'); throw new Error('Series not found');
} }
return data[0]; return response.data[0];
} catch (e) { } catch (e) {
logger.error('Error retrieving series by tvdb ID', { logger.error('Error retrieving series by tvdb ID', {
label: 'Sonarr API', label: 'Sonarr API',
@@ -187,27 +191,27 @@ class SonarrAPI extends ServarrBase<{
series.tags = options.tags ?? series.tags; series.tags = options.tags ?? series.tags;
series.seasons = this.buildSeasonList(options.seasons, series.seasons); series.seasons = this.buildSeasonList(options.seasons, series.seasons);
const newSeriesData = await this.put<SonarrSeries>( const newSeriesResponse = await this.axios.put<SonarrSeries>(
'/series', '/series',
series as any series
); );
if (newSeriesData.id) { if (newSeriesResponse.data.id) {
logger.info('Updated existing series in Sonarr.', { logger.info('Updated existing series in Sonarr.', {
label: 'Sonarr', label: 'Sonarr',
seriesId: newSeriesData.id, seriesId: newSeriesResponse.data.id,
seriesTitle: newSeriesData.title, seriesTitle: newSeriesResponse.data.title,
}); });
logger.debug('Sonarr update details', { logger.debug('Sonarr update details', {
label: 'Sonarr', label: 'Sonarr',
movie: newSeriesData, movie: newSeriesResponse.data,
}); });
if (options.searchNow) { if (options.searchNow) {
this.searchSeries(newSeriesData.id); this.searchSeries(newSeriesResponse.data.id);
} }
return newSeriesData; return newSeriesResponse.data;
} else { } else {
logger.error('Failed to update series in Sonarr', { logger.error('Failed to update series in Sonarr', {
label: 'Sonarr', label: 'Sonarr',
@@ -217,35 +221,38 @@ class SonarrAPI extends ServarrBase<{
} }
} }
const createdSeriesData = await this.post<SonarrSeries>('/series', { const createdSeriesResponse = await this.axios.post<SonarrSeries>(
tvdbId: options.tvdbid, '/series',
title: options.title, {
qualityProfileId: options.profileId, tvdbId: options.tvdbid,
languageProfileId: options.languageProfileId, title: options.title,
seasons: this.buildSeasonList( qualityProfileId: options.profileId,
options.seasons, languageProfileId: options.languageProfileId,
series.seasons.map((season) => ({ seasons: this.buildSeasonList(
seasonNumber: season.seasonNumber, options.seasons,
// We force all seasons to false if its the first request series.seasons.map((season) => ({
monitored: false, seasonNumber: season.seasonNumber,
})) // We force all seasons to false if its the first request
), monitored: false,
tags: options.tags, }))
seasonFolder: options.seasonFolder, ),
monitored: options.monitored, tags: options.tags,
rootFolderPath: options.rootFolderPath, seasonFolder: options.seasonFolder,
seriesType: options.seriesType, monitored: options.monitored,
addOptions: { rootFolderPath: options.rootFolderPath,
ignoreEpisodesWithFiles: true, seriesType: options.seriesType,
searchForMissingEpisodes: options.searchNow, addOptions: {
}, ignoreEpisodesWithFiles: true,
} as Partial<SonarrSeries>); searchForMissingEpisodes: options.searchNow,
},
} as Partial<SonarrSeries>
);
if (createdSeriesData.id) { if (createdSeriesResponse.data.id) {
logger.info('Sonarr accepted request', { label: 'Sonarr' }); logger.info('Sonarr accepted request', { label: 'Sonarr' });
logger.debug('Sonarr add details', { logger.debug('Sonarr add details', {
label: 'Sonarr', label: 'Sonarr',
movie: createdSeriesData, movie: createdSeriesResponse.data,
}); });
} else { } else {
logger.error('Failed to add movie to Sonarr', { logger.error('Failed to add movie to Sonarr', {
@@ -255,7 +262,7 @@ class SonarrAPI extends ServarrBase<{
throw new Error('Failed to add series to Sonarr'); throw new Error('Failed to add series to Sonarr');
} }
return createdSeriesData; return createdSeriesResponse.data;
} catch (e) { } catch (e) {
let errorData; let errorData;
try { try {
@@ -340,13 +347,14 @@ class SonarrAPI extends ServarrBase<{
return newSeasons; return newSeasons;
} }
public removeSerie = async (serieId: number): Promise<void> => { public removeSerie = async (serieId: number): Promise<void> => {
try { try {
const { id, title } = await this.getSeriesByTvdbId(serieId); const { id, title } = await this.getSeriesByTvdbId(serieId);
await this.delete(`/series/${id}`, { await this.axios.delete(`/series/${id}`, {
deleteFiles: 'true', params: {
addImportExclusion: 'false', deleteFiles: true,
addImportExclusion: false,
},
}); });
logger.info(`[Radarr] Removed serie ${title}`); logger.info(`[Radarr] Removed serie ${title}`);
} catch (e) { } catch (e) {

View File

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

View File

@@ -113,8 +113,8 @@ class TheMovieDb extends ExternalAPI {
{ {
nodeCache: cacheManager.getCache('tmdb').data, nodeCache: cacheManager.getCache('tmdb').data,
rateLimit: { rateLimit: {
maxRequests: 20,
maxRPS: 50, maxRPS: 50,
id: 'tmdb',
}, },
} }
); );
@@ -130,10 +130,7 @@ class TheMovieDb extends ExternalAPI {
}: SearchOptions): Promise<TmdbSearchMultiResponse> => { }: SearchOptions): Promise<TmdbSearchMultiResponse> => {
try { try {
const data = await this.get<TmdbSearchMultiResponse>('/search/multi', { const data = await this.get<TmdbSearchMultiResponse>('/search/multi', {
query, params: { query, page, include_adult: includeAdult, language },
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
}); });
return data; return data;
@@ -156,11 +153,13 @@ class TheMovieDb extends ExternalAPI {
}: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => { }: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => {
try { try {
const data = await this.get<TmdbSearchMovieResponse>('/search/movie', { const data = await this.get<TmdbSearchMovieResponse>('/search/movie', {
query, params: {
page: page.toString(), query,
include_adult: includeAdult ? 'true' : 'false', page,
language, include_adult: includeAdult,
primary_release_year: year?.toString() || '', language,
primary_release_year: year,
},
}); });
return data; return data;
@@ -183,11 +182,13 @@ class TheMovieDb extends ExternalAPI {
}: SingleSearchOptions): Promise<TmdbSearchTvResponse> => { }: SingleSearchOptions): Promise<TmdbSearchTvResponse> => {
try { try {
const data = await this.get<TmdbSearchTvResponse>('/search/tv', { const data = await this.get<TmdbSearchTvResponse>('/search/tv', {
query, params: {
page: page.toString(), query,
include_adult: includeAdult ? 'true' : 'false', page,
language, include_adult: includeAdult,
first_air_date_year: year?.toString() || '', language,
first_air_date_year: year,
},
}); });
return data; return data;
@@ -210,7 +211,7 @@ class TheMovieDb extends ExternalAPI {
}): Promise<TmdbPersonDetails> => { }): Promise<TmdbPersonDetails> => {
try { try {
const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, { const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, {
language, params: { language },
}); });
return data; return data;
@@ -230,7 +231,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbPersonCombinedCredits>( const data = await this.get<TmdbPersonCombinedCredits>(
`/person/${personId}/combined_credits`, `/person/${personId}/combined_credits`,
{ {
language, params: { language },
} }
); );
@@ -253,9 +254,11 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbMovieDetails>( const data = await this.get<TmdbMovieDetails>(
`/movie/${movieId}`, `/movie/${movieId}`,
{ {
language, params: {
append_to_response: language,
'credits,external_ids,videos,keywords,release_dates,watch/providers', append_to_response:
'credits,external_ids,videos,keywords,release_dates,watch/providers',
},
}, },
43200 43200
); );
@@ -277,9 +280,11 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbTvDetails>( const data = await this.get<TmdbTvDetails>(
`/tv/${tvId}`, `/tv/${tvId}`,
{ {
language, params: {
append_to_response: language,
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers', append_to_response:
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
},
}, },
43200 43200
); );
@@ -303,8 +308,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSeasonWithEpisodes>( const data = await this.get<TmdbSeasonWithEpisodes>(
`/tv/${tvId}/season/${seasonNumber}`, `/tv/${tvId}/season/${seasonNumber}`,
{ {
language: language || '', params: {
append_to_response: 'external_ids', language,
append_to_response: 'external_ids',
},
} }
); );
@@ -327,8 +334,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>( const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/recommendations`, `/movie/${movieId}/recommendations`,
{ {
page: page.toString(), params: {
language, page,
language,
},
} }
); );
@@ -351,8 +360,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>( const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/similar`, `/movie/${movieId}/similar`,
{ {
page: page.toString(), params: {
language, page,
language,
},
} }
); );
@@ -375,8 +386,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>( const data = await this.get<TmdbSearchMovieResponse>(
`/keyword/${keywordId}/movies`, `/keyword/${keywordId}/movies`,
{ {
page: page.toString(), params: {
language, page,
language,
},
} }
); );
@@ -399,8 +412,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchTvResponse>( const data = await this.get<TmdbSearchTvResponse>(
`/tv/${tvId}/recommendations`, `/tv/${tvId}/recommendations`,
{ {
page: page.toString(), params: {
language, page,
language,
},
} }
); );
@@ -423,8 +438,10 @@ class TheMovieDb extends ExternalAPI {
}): Promise<TmdbSearchTvResponse> { }): Promise<TmdbSearchTvResponse> {
try { try {
const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, { const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, {
page: page.toString(), params: {
language, page,
language,
},
}); });
return data; return data;
@@ -465,38 +482,40 @@ class TheMovieDb extends ExternalAPI {
.split('T')[0]; .split('T')[0];
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', { const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
sort_by: sortBy, params: {
page: page.toString(), sort_by: sortBy,
include_adult: includeAdult ? 'true' : 'false', page,
language, include_adult: includeAdult,
region: this.region || '', language,
with_original_language: region: this.region,
originalLanguage && originalLanguage !== 'all' with_original_language:
? originalLanguage originalLanguage && originalLanguage !== 'all'
: originalLanguage === 'all' ? originalLanguage
? '' : originalLanguage === 'all'
: this.originalLanguage || '', ? undefined
// Set our release date values, but check if one is set and not the other, : this.originalLanguage,
// so we can force a past date or a future date. TMDB Requires both values if one is set! // Set our release date values, but check if one is set and not the other,
'primary_release_date.gte': // so we can force a past date or a future date. TMDB Requires both values if one is set!
!primaryReleaseDateGte && primaryReleaseDateLte 'primary_release_date.gte':
? defaultPastDate !primaryReleaseDateGte && primaryReleaseDateLte
: primaryReleaseDateGte || '', ? defaultPastDate
'primary_release_date.lte': : primaryReleaseDateGte,
!primaryReleaseDateLte && primaryReleaseDateGte 'primary_release_date.lte':
? defaultFutureDate !primaryReleaseDateLte && primaryReleaseDateGte
: primaryReleaseDateLte || '', ? defaultFutureDate
with_genres: genre || '', : primaryReleaseDateLte,
with_companies: studio || '', with_genres: genre,
with_keywords: keywords || '', with_companies: studio,
'with_runtime.gte': withRuntimeGte || '', with_keywords: keywords,
'with_runtime.lte': withRuntimeLte || '', 'with_runtime.gte': withRuntimeGte,
'vote_average.gte': voteAverageGte || '', 'with_runtime.lte': withRuntimeLte,
'vote_average.lte': voteAverageLte || '', 'vote_average.gte': voteAverageGte,
'vote_count.gte': voteCountGte || '', 'vote_average.lte': voteAverageLte,
'vote_count.lte': voteCountLte || '', 'vote_count.gte': voteCountGte,
watch_region: watchRegion || '', 'vote_count.lte': voteCountLte,
with_watch_providers: watchProviders || '', watch_region: watchRegion,
with_watch_providers: watchProviders,
},
}); });
return data; return data;
@@ -538,41 +557,43 @@ class TheMovieDb extends ExternalAPI {
.split('T')[0]; .split('T')[0];
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', { const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
sort_by: sortBy, params: {
page: page.toString(), sort_by: sortBy,
language, page: page.toString(),
region: this.region || '', language,
// Set our release date values, but check if one is set and not the other, region: this.region || '',
// so we can force a past date or a future date. TMDB Requires both values if one is set! // Set our release date values, but check if one is set and not the other,
'first_air_date.gte': // so we can force a past date or a future date. TMDB Requires both values if one is set!
!firstAirDateGte && firstAirDateLte 'first_air_date.gte':
? defaultPastDate !firstAirDateGte && firstAirDateLte
: firstAirDateGte || '', ? defaultPastDate
'first_air_date.lte': : firstAirDateGte || '',
!firstAirDateLte && firstAirDateGte 'first_air_date.lte':
? defaultFutureDate !firstAirDateLte && firstAirDateGte
: firstAirDateLte || '', ? defaultFutureDate
with_original_language: : firstAirDateLte || '',
originalLanguage && originalLanguage !== 'all' with_original_language:
? originalLanguage originalLanguage && originalLanguage !== 'all'
: originalLanguage === 'all' ? originalLanguage
? '' : originalLanguage === 'all'
: this.originalLanguage || '', ? ''
include_null_first_air_dates: includeEmptyReleaseDate : this.originalLanguage || '',
? 'true' include_null_first_air_dates: includeEmptyReleaseDate
: 'false', ? 'true'
with_genres: genre || '', : 'false',
with_networks: network?.toString() || '', with_genres: genre || '',
with_keywords: keywords || '', with_networks: network?.toString() || '',
'with_runtime.gte': withRuntimeGte || '', with_keywords: keywords || '',
'with_runtime.lte': withRuntimeLte || '', 'with_runtime.gte': withRuntimeGte || '',
'vote_average.gte': voteAverageGte || '', 'with_runtime.lte': withRuntimeLte || '',
'vote_average.lte': voteAverageLte || '', 'vote_average.gte': voteAverageGte || '',
'vote_count.gte': voteCountGte || '', 'vote_average.lte': voteAverageLte || '',
'vote_count.lte': voteCountLte || '', 'vote_count.gte': voteCountGte || '',
with_watch_providers: watchProviders || '', 'vote_count.lte': voteCountLte || '',
watch_region: watchRegion || '', with_watch_providers: watchProviders || '',
with_status: withStatus || '', watch_region: watchRegion || '',
with_status: withStatus || '',
},
}); });
return data; return data;
@@ -592,10 +613,12 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbUpcomingMoviesResponse>( const data = await this.get<TmdbUpcomingMoviesResponse>(
'/movie/upcoming', '/movie/upcoming',
{ {
page: page.toString(), params: {
language, page,
region: this.region || '', language,
originalLanguage: this.originalLanguage || '', region: this.region,
originalLanguage: this.originalLanguage,
},
} }
); );
@@ -618,9 +641,11 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMultiResponse>( const data = await this.get<TmdbSearchMultiResponse>(
`/trending/all/${timeWindow}`, `/trending/all/${timeWindow}`,
{ {
page: page.toString(), params: {
language, page,
region: this.region || '', language,
region: this.region,
},
} }
); );
@@ -641,7 +666,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>( const data = await this.get<TmdbSearchMovieResponse>(
`/trending/movie/${timeWindow}`, `/trending/movie/${timeWindow}`,
{ {
page: page.toString(), params: {
page,
},
} }
); );
@@ -662,7 +689,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchTvResponse>( const data = await this.get<TmdbSearchTvResponse>(
`/trending/tv/${timeWindow}`, `/trending/tv/${timeWindow}`,
{ {
page: page.toString(), params: {
page,
},
} }
); );
@@ -691,8 +720,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbExternalIdResponse>( const data = await this.get<TmdbExternalIdResponse>(
`/find/${externalId}`, `/find/${externalId}`,
{ {
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id', params: {
language, external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
language,
},
} }
); );
@@ -782,7 +813,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbCollection>( const data = await this.get<TmdbCollection>(
`/collection/${collectionId}`, `/collection/${collectionId}`,
{ {
language, params: {
language,
},
} }
); );
@@ -855,7 +888,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbGenresResult>( const data = await this.get<TmdbGenresResult>(
'/genre/movie/list', '/genre/movie/list',
{ {
language, params: {
language,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -867,7 +902,9 @@ class TheMovieDb extends ExternalAPI {
const englishData = await this.get<TmdbGenresResult>( const englishData = await this.get<TmdbGenresResult>(
'/genre/movie/list', '/genre/movie/list',
{ {
language: 'en', params: {
language: 'en',
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -902,7 +939,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbGenresResult>( const data = await this.get<TmdbGenresResult>(
'/genre/tv/list', '/genre/tv/list',
{ {
language, params: {
language,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -914,7 +953,9 @@ class TheMovieDb extends ExternalAPI {
const englishData = await this.get<TmdbGenresResult>( const englishData = await this.get<TmdbGenresResult>(
'/genre/tv/list', '/genre/tv/list',
{ {
language: 'en', params: {
language: 'en',
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -969,8 +1010,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbKeywordSearchResponse>( const data = await this.get<TmdbKeywordSearchResponse>(
'/search/keyword', '/search/keyword',
{ {
query, params: {
page: page.toString(), query,
page,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -992,8 +1035,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbCompanySearchResponse>( const data = await this.get<TmdbCompanySearchResponse>(
'/search/company', '/search/company',
{ {
query, params: {
page: page.toString(), query,
page,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -1013,7 +1058,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>( const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
'/watch/providers/regions', '/watch/providers/regions',
{ {
language: language ? this.originalLanguage || '' : '', params: {
language: language ?? this.originalLanguage,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -1037,8 +1084,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>( const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/movie', '/watch/providers/movie',
{ {
language: language ? this.originalLanguage || '' : '', params: {
watch_region: watchRegion, language: language ?? this.originalLanguage,
watch_region: watchRegion,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -1062,8 +1111,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>( const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/tv', '/watch/providers/tv',
{ {
language: language ? this.originalLanguage || '' : '', params: {
watch_region: watchRegion, language: language ?? this.originalLanguage,
watch_region: watchRegion,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );

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.jellyfinMediaId}&context=home&serverId=${serverId}`; this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`;
} }
} }
} }

View File

@@ -19,8 +19,11 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import clearCookies from '@server/middleware/clearcookies'; import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes'; import routes from '@server/routes';
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';
@@ -32,17 +35,10 @@ import * as OpenApiValidator from 'express-openapi-validator';
import type { Store } from 'express-session'; import type { Store } from 'express-session';
import session from 'express-session'; import session from 'express-session';
import next from 'next'; import next from 'next';
import dns from 'node:dns';
import net from 'node:net';
import path from 'path'; import path from 'path';
import swaggerUi from 'swagger-ui-express'; import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs'; import YAML from 'yamljs';
if (process.env.forceIpv4First === 'true') {
dns.setDefaultResultOrder('ipv4first');
net.setDefaultAutoSelectFamily(false);
}
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
logger.info(`Starting Overseerr version ${getAppVersion()}`); logger.info(`Starting Overseerr version ${getAppVersion()}`);
@@ -50,6 +46,12 @@ const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev }); const app = next({ dev });
const handle = app.getRequestHandler(); const handle = app.getRequestHandler();
if (!appDataPermissions()) {
logger.error(
'Something went wrong while checking config folder! Please ensure the config folder is set up properly.\nhttps://docs.jellyseerr.dev/getting-started'
);
}
app app
.prepare() .prepare()
.then(async () => { .then(async () => {
@@ -66,6 +68,11 @@ app
const settings = await getSettings().load(); const settings = await getSettings().load();
restartFlag.initializeSettings(settings.main); restartFlag.initializeSettings(settings.main);
// Register HTTP proxy
if (settings.main.proxy.enabled) {
await createCustomProxyAgent(settings.main.proxy);
}
// Migrate library types // Migrate library types
if ( if (
settings.plex.libraries.length > 1 && settings.plex.libraries.length > 1 &&
@@ -174,7 +181,7 @@ app
}, },
store: new TypeormStore({ store: new TypeormStore({
cleanupLimit: 2, cleanupLimit: 2,
ttl: 1000 * 60 * 60 * 24 * 30, ttl: 60 * 60 * 24 * 30,
}).connect(sessionRespository) as Store, }).connect(sessionRespository) as Store,
}) })
); );
@@ -202,6 +209,7 @@ app
// Do not set cookies so CDNs can cache them // Do not set cookies so CDNs can cache them
server.use('/imageproxy', clearCookies, imageproxy); server.use('/imageproxy', clearCookies, imageproxy);
server.use('/avatarproxy', clearCookies, avatarproxy);
server.get('*', (req, res) => handle(req, res)); server.get('*', (req, res) => handle(req, res));
server.use( server.use(

View File

@@ -58,7 +58,7 @@ export interface CacheItem {
export interface CacheResponse { export interface CacheResponse {
apiCaches: CacheItem[]; apiCaches: CacheItem[];
imageCache: Record<'tmdb', { size: number; imageCount: number }>; imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
} }
export interface StatusResponse { export interface StatusResponse {

View File

@@ -227,6 +227,9 @@ export const startJobs = (): void => {
}); });
// Clean TMDB image cache // Clean TMDB image cache
ImageProxy.clearCache('tmdb'); ImageProxy.clearCache('tmdb');
// Clean users avatar image cache
ImageProxy.clearCache('avatar');
}), }),
}); });

View File

@@ -85,6 +85,7 @@ 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) => ({
@@ -105,7 +106,7 @@ class DownloadTracker {
{ label: 'Download Tracker' } { label: 'Download Tracker' }
); );
} }
} catch { } catch (e) {
logger.error( logger.error(
`Unable to get queue from Radarr server: ${server.name}`, `Unable to get queue from Radarr server: ${server.name}`,
{ {
@@ -162,6 +163,7 @@ 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,8 +1,9 @@
import logger from '@server/logger'; import logger from '@server/logger';
import type { RateLimitOptions } from '@server/utils/rateLimit'; import axios from 'axios';
import rateLimit from '@server/utils/rateLimit'; import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { promises } from 'fs'; import { promises } from 'fs';
import mime from 'mime/lite';
import path, { join } from 'path'; import path, { join } from 'path';
type ImageResponse = { type ImageResponse = {
@@ -11,7 +12,7 @@ type ImageResponse = {
curRevalidate: number; curRevalidate: number;
isStale: boolean; isStale: boolean;
etag: string; etag: string;
extension: string; extension: string | null;
cacheKey: string; cacheKey: string;
cacheMiss: boolean; cacheMiss: boolean;
}; };
@@ -27,29 +28,45 @@ class ImageProxy {
let deletedImages = 0; let deletedImages = 0;
const cacheDirectory = path.join(baseCacheDirectory, key); const cacheDirectory = path.join(baseCacheDirectory, key);
const files = await promises.readdir(cacheDirectory); try {
const files = await promises.readdir(cacheDirectory);
for (const file of files) { for (const file of files) {
const filePath = path.join(cacheDirectory, file); const filePath = path.join(cacheDirectory, file);
const stat = await promises.lstat(filePath); const stat = await promises.lstat(filePath);
if (stat.isDirectory()) { if (stat.isDirectory()) {
const imageFiles = await promises.readdir(filePath); const imageFiles = await promises.readdir(filePath);
for (const imageFile of imageFiles) { for (const imageFile of imageFiles) {
const [, expireAtSt] = imageFile.split('.'); const [, expireAtSt] = imageFile.split('.');
const expireAt = Number(expireAtSt); const expireAt = Number(expireAtSt);
const now = Date.now(); const now = Date.now();
if (now > expireAt) { if (now > expireAt) {
await promises.rm(path.join(filePath, imageFile)); await promises.rm(path.join(filePath), {
deletedImages += 1; recursive: true,
});
deletedImages += 1;
}
} }
} }
} }
} catch (e) {
if (e.code === 'ENOENT') {
logger.error('Directory not found', {
label: 'Image Cache',
message: e.message,
});
} else {
logger.error('Failed to read directory', {
label: 'Image Cache',
message: e.message,
});
}
} }
logger.info(`Cleared ${deletedImages} stale image(s) from cache`, { logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, {
label: 'Image Cache', label: 'Image Cache',
}); });
} }
@@ -69,62 +86,80 @@ class ImageProxy {
} }
private static async getDirectorySize(dir: string): Promise<number> { private static async getDirectorySize(dir: string): Promise<number> {
const files = await promises.readdir(dir, { try {
withFileTypes: true, const files = await promises.readdir(dir, {
}); withFileTypes: true,
});
const paths = files.map(async (file) => { const paths = files.map(async (file) => {
const path = join(dir, file.name); const path = join(dir, file.name);
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path); if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
if (file.isFile()) { if (file.isFile()) {
const { size } = await promises.stat(path); const { size } = await promises.stat(path);
return size; return size;
}
return 0;
});
return (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
} }
}
return 0; return 0;
});
return (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
} }
private static async getImageCount(dir: string) { private static async getImageCount(dir: string) {
const files = await promises.readdir(dir); try {
const files = await promises.readdir(dir);
return files.length; return files.length;
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
}
}
return 0;
} }
private fetch: typeof fetch; private axios;
private cacheVersion; private cacheVersion;
private key; private key;
private baseUrl;
constructor( constructor(
key: string, key: string,
baseUrl: string, baseUrl: string,
options: { options: {
cacheVersion?: number; cacheVersion?: number;
rateLimitOptions?: RateLimitOptions; rateLimitOptions?: rateLimitOptions;
headers?: Record<string, unknown>;
} = {} } = {}
) { ) {
this.cacheVersion = options.cacheVersion ?? 1; this.cacheVersion = options.cacheVersion ?? 1;
this.baseUrl = baseUrl;
this.key = key; this.key = key;
this.axios = axios.create({
baseURL: baseUrl,
headers: options.headers,
});
if (options.rateLimitOptions) { if (options.rateLimitOptions) {
this.fetch = rateLimit(fetch, { this.axios = rateLimit(this.axios, options.rateLimitOptions);
...options.rateLimitOptions,
});
} else {
this.fetch = fetch;
} }
} }
public async getImage(path: string): Promise<ImageResponse> { public async getImage(
path: string,
fallbackPath?: string
): Promise<ImageResponse> {
const cacheKey = this.getCacheKey(path); const cacheKey = this.getCacheKey(path);
const imageResponse = await this.get(cacheKey); const imageResponse = await this.get(cacheKey);
@@ -133,7 +168,11 @@ class ImageProxy {
const newImage = await this.set(path, cacheKey); const newImage = await this.set(path, cacheKey);
if (!newImage) { if (!newImage) {
throw new Error('Failed to load image'); if (fallbackPath) {
return await this.getImage(fallbackPath);
} else {
throw new Error('Failed to load image');
}
} }
return newImage; return newImage;
@@ -147,6 +186,27 @@ class ImageProxy {
return imageResponse; return imageResponse;
} }
public async clearCachedImage(path: string) {
// find cacheKey
const cacheKey = this.getCacheKey(path);
try {
const directory = join(this.getCacheDirectory(), cacheKey);
const files = await promises.readdir(directory);
await promises.rm(directory, { recursive: true });
logger.info(`Cleared ${files[0]} from cache 'avatar'`, {
label: 'Image Cache',
});
} catch (e) {
logger.error('Failed to clear cached image', {
label: 'Image Cache',
message: e.message,
});
}
}
private async get(cacheKey: string): Promise<ImageResponse | null> { private async get(cacheKey: string): Promise<ImageResponse | null> {
try { try {
const directory = join(this.getCacheDirectory(), cacheKey); const directory = join(this.getCacheDirectory(), cacheKey);
@@ -185,20 +245,23 @@ class ImageProxy {
): Promise<ImageResponse | null> { ): Promise<ImageResponse | null> {
try { try {
const directory = join(this.getCacheDirectory(), cacheKey); const directory = join(this.getCacheDirectory(), cacheKey);
const href = const response = await this.axios.get(path, {
this.baseUrl + responseType: 'arraybuffer',
(this.baseUrl.endsWith('/') ? '' : '/') + });
(path.startsWith('/') ? path.slice(1) : path);
const response = await this.fetch(href);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extension = path.split('.').pop() ?? ''; const buffer = Buffer.from(response.data, 'binary');
const maxAge = Number(
(response.headers.get('cache-control') ?? '0').split('=')[1] const extension = mime.getExtension(
response.headers['Content-Type']?.toString() ?? ''
); );
let maxAge = Number(
(response.headers['Cache-Control']?.toString() ?? '0').split('=')[1]
);
if (!maxAge) maxAge = 86400;
const expireAt = Date.now() + maxAge * 1000; const expireAt = Date.now() + maxAge * 1000;
const etag = (response.headers.get('etag') ?? '').replace(/"/g, ''); const etag = (response.headers.etag ?? '').replace(/"/g, '');
await this.writeToCacheDir( await this.writeToCacheDir(
directory, directory,
@@ -232,7 +295,7 @@ class ImageProxy {
private async writeToCacheDir( private async writeToCacheDir(
dir: string, dir: string,
extension: string, extension: string | null,
maxAge: number, maxAge: number,
expireAt: number, expireAt: number,
buffer: Buffer, buffer: Buffer,

View File

@@ -4,6 +4,7 @@ import { User } from '@server/entity/User';
import type { NotificationAgentDiscord } from '@server/lib/settings'; import type { NotificationAgentDiscord } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings'; import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { import {
hasNotificationType, hasNotificationType,
Notification, Notification,
@@ -291,23 +292,14 @@ class DiscordAgent
} }
} }
const response = await fetch(settings.options.webhookUrl, { await axios.post(settings.options.webhookUrl, {
method: 'POST', username: settings.options.botUsername
headers: { ? settings.options.botUsername
'Content-Type': 'application/json', : getSettings().main.applicationTitle,
}, avatar_url: settings.options.botAvatarUrl,
body: JSON.stringify({ embeds: [this.buildEmbed(type, payload)],
username: settings.options.botUsername content: userMentions.join(' '),
? settings.options.botUsername } as DiscordWebhookPayload);
: getSettings().main.applicationTitle,
avatar_url: settings.options.botAvatarUrl,
embeds: [this.buildEmbed(type, payload)],
content: userMentions.join(' '),
} as DiscordWebhookPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
return true; return true;
} catch (e) { } catch (e) {

View File

@@ -2,6 +2,7 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import type { NotificationAgentGotify } from '@server/lib/settings'; import type { NotificationAgentGotify } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent'; import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent'; import { BaseAgent } from './agent';
@@ -132,16 +133,7 @@ class GotifyAgent
const endpoint = `${settings.options.url}/message?token=${settings.options.token}`; const endpoint = `${settings.options.url}/message?token=${settings.options.token}`;
const notificationPayload = this.getNotificationPayload(type, payload); const notificationPayload = this.getNotificationPayload(type, payload);
const response = await fetch(endpoint, { await axios.post(endpoint, notificationPayload);
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(notificationPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
return true; return true;
} catch (e) { } catch (e) {

View File

@@ -3,6 +3,7 @@ import { MediaStatus } from '@server/constants/media';
import type { NotificationAgentLunaSea } from '@server/lib/settings'; import type { NotificationAgentLunaSea } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent'; import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent'; import { BaseAgent } from './agent';
@@ -100,23 +101,19 @@ class LunaSeaAgent
}); });
try { try {
const response = await fetch(settings.options.webhookUrl, { await axios.post(
method: 'POST', settings.options.webhookUrl,
headers: settings.options.profileName this.buildPayload(type, payload),
settings.options.profileName
? { ? {
'Content-Type': 'application/json', headers: {
Authorization: `Basic ${Buffer.from(
`${settings.options.profileName}:`
).toString('base64')}`,
},
} }
: { : undefined
'Content-Type': 'application/json', );
Authorization: `Basic ${Buffer.from(
`${settings.options.profileName}:`
).toString('base64')}`,
},
body: JSON.stringify(this.buildPayload(type, payload)),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
return true; return true;
} catch (e) { } catch (e) {

View File

@@ -5,6 +5,7 @@ import { User } from '@server/entity/User';
import type { NotificationAgentPushbullet } from '@server/lib/settings'; import type { NotificationAgentPushbullet } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings'; import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { import {
hasNotificationType, hasNotificationType,
Notification, Notification,
@@ -122,20 +123,15 @@ class PushbulletAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(
method: 'POST', endpoint,
headers: { { ...notificationPayload, channel_tag: settings.options.channelTag },
'Content-Type': 'application/json', {
'Access-Token': settings.options.accessToken, headers: {
}, 'Access-Token': settings.options.accessToken,
body: JSON.stringify({ },
...notificationPayload, }
channel_tag: settings.options.channelTag, );
}),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData; let errorData;
try { try {
@@ -174,17 +170,11 @@ class PushbulletAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(endpoint, notificationPayload, {
method: 'POST',
headers: { headers: {
'Content-Type': 'application/json',
'Access-Token': payload.notifyUser.settings.pushbulletAccessToken, 'Access-Token': payload.notifyUser.settings.pushbulletAccessToken,
}, },
body: JSON.stringify(notificationPayload),
}); });
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData; let errorData;
try { try {
@@ -235,17 +225,11 @@ class PushbulletAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(endpoint, notificationPayload, {
method: 'POST',
headers: { headers: {
'Content-Type': 'application/json',
'Access-Token': user.settings.pushbulletAccessToken, 'Access-Token': user.settings.pushbulletAccessToken,
}, },
body: JSON.stringify(notificationPayload),
}); });
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData; let errorData;
try { try {

View File

@@ -5,6 +5,7 @@ import { User } from '@server/entity/User';
import type { NotificationAgentPushover } from '@server/lib/settings'; import type { NotificationAgentPushover } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings'; import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { import {
hasNotificationType, hasNotificationType,
Notification, Notification,
@@ -51,15 +52,12 @@ class PushoverAgent
imageUrl: string imageUrl: string
): Promise<Partial<PushoverImagePayload>> { ): Promise<Partial<PushoverImagePayload>> {
try { try {
const response = await fetch(imageUrl); const response = await axios.get(imageUrl, {
if (!response.ok) { responseType: 'arraybuffer',
throw new Error(response.statusText, { cause: response }); });
} const base64 = Buffer.from(response.data, 'binary').toString('base64');
const arrayBuffer = await response.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString('base64');
const contentType = ( const contentType = (
response.headers.get('Content-Type') || response.headers['Content-Type'] || response.headers['content-type']
response.headers.get('content-type')
)?.toString(); )?.toString();
return { return {
@@ -210,21 +208,12 @@ class PushoverAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(endpoint, {
method: 'POST', ...notificationPayload,
headers: { token: settings.options.accessToken,
'Content-Type': 'application/json', user: settings.options.userToken,
}, sound: settings.options.sound,
body: JSON.stringify({ } as PushoverPayload);
...notificationPayload,
token: settings.options.accessToken,
user: settings.options.userToken,
sound: settings.options.sound,
} as PushoverPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData; let errorData;
try { try {
@@ -266,21 +255,12 @@ class PushoverAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(endpoint, {
method: 'POST', ...notificationPayload,
headers: { token: payload.notifyUser.settings.pushoverApplicationToken,
'Content-Type': 'application/json', user: payload.notifyUser.settings.pushoverUserKey,
}, sound: payload.notifyUser.settings.pushoverSound,
body: JSON.stringify({ } as PushoverPayload);
...notificationPayload,
token: payload.notifyUser.settings.pushoverApplicationToken,
user: payload.notifyUser.settings.pushoverUserKey,
sound: payload.notifyUser.settings.pushoverSound,
} as PushoverPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData; let errorData;
try { try {
@@ -332,20 +312,11 @@ class PushoverAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(endpoint, {
method: 'POST', ...notificationPayload,
headers: { token: user.settings.pushoverApplicationToken,
'Content-Type': 'application/json', user: user.settings.pushoverUserKey,
}, } as PushoverPayload);
body: JSON.stringify({
...notificationPayload,
token: user.settings.pushoverApplicationToken,
user: user.settings.pushoverUserKey,
} as PushoverPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData; let errorData;
try { try {

View File

@@ -2,6 +2,7 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import type { NotificationAgentSlack } from '@server/lib/settings'; import type { NotificationAgentSlack } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent'; import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent'; import { BaseAgent } from './agent';
@@ -237,16 +238,10 @@ class SlackAgent
subject: payload.subject, subject: payload.subject,
}); });
try { try {
const response = await fetch(settings.options.webhookUrl, { await axios.post(
method: 'POST', settings.options.webhookUrl,
headers: { this.buildEmbed(type, payload)
'Content-Type': 'application/json', );
},
body: JSON.stringify(this.buildEmbed(type, payload)),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
return true; return true;
} catch (e) { } catch (e) {

View File

@@ -5,6 +5,7 @@ import { User } from '@server/entity/User';
import type { NotificationAgentTelegram } from '@server/lib/settings'; import type { NotificationAgentTelegram } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings'; import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { import {
hasNotificationType, hasNotificationType,
Notification, Notification,
@@ -174,20 +175,11 @@ class TelegramAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(endpoint, {
method: 'POST', ...notificationPayload,
headers: { chat_id: settings.options.chatId,
'Content-Type': 'application/json', disable_notification: !!settings.options.sendSilently,
}, } as TelegramMessagePayload | TelegramPhotoPayload);
body: JSON.stringify({
...notificationPayload,
chat_id: settings.options.chatId,
disable_notification: !!settings.options.sendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData; let errorData;
try { try {
@@ -225,21 +217,12 @@ class TelegramAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(endpoint, {
method: 'POST', ...notificationPayload,
headers: { chat_id: payload.notifyUser.settings.telegramChatId,
'Content-Type': 'application/json', disable_notification:
}, !!payload.notifyUser.settings.telegramSendSilently,
body: JSON.stringify({ } as TelegramMessagePayload | TelegramPhotoPayload);
...notificationPayload,
chat_id: payload.notifyUser.settings.telegramChatId,
disable_notification:
!!payload.notifyUser.settings.telegramSendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData; let errorData;
try { try {
@@ -288,20 +271,11 @@ class TelegramAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(endpoint, {
method: 'POST', ...notificationPayload,
headers: { chat_id: user.settings.telegramChatId,
'Content-Type': 'application/json', disable_notification: !!user.settings?.telegramSendSilently,
}, } as TelegramMessagePayload | TelegramPhotoPayload);
body: JSON.stringify({
...notificationPayload,
chat_id: user.settings.telegramChatId,
disable_notification: !!user.settings?.telegramSendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData; let errorData;
try { try {

View File

@@ -3,6 +3,7 @@ import { MediaStatus } from '@server/constants/media';
import type { NotificationAgentWebhook } from '@server/lib/settings'; import type { NotificationAgentWebhook } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { get } from 'lodash'; import { get } from 'lodash';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent'; import type { NotificationAgent, NotificationPayload } from './agent';
@@ -177,19 +178,17 @@ class WebhookAgent
}); });
try { try {
const response = await fetch(settings.options.webhookUrl, { await axios.post(
method: 'POST', settings.options.webhookUrl,
headers: { this.buildPayload(type, payload),
'Content-Type': 'application/json', settings.options.authHeader
...(settings.options.authHeader ? {
? { Authorization: settings.options.authHeader } headers: {
: {}), Authorization: settings.options.authHeader,
}, },
body: JSON.stringify(this.buildPayload(type, payload)), }
}); : undefined
if (!response.ok) { );
throw new Error(response.statusText, { cause: response });
}
return true; return true;
} catch (e) { } catch (e) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,30 +1,100 @@
import type { AllSettings } from '@server/lib/settings'; import type { AllSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import fs from 'fs'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
const migrationsDir = path.join(__dirname, 'migrations'); const migrationsDir = path.join(__dirname, 'migrations');
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) {
migrated = await migration(migrated); try {
logger.debug(`Checking migration '${migration}'...`, {
label: 'Settings Migrator',
});
const { default: migrationFn } = await import(
path.join(migrationsDir, migration)
);
const newSettings = await migrationFn(structuredClone(migrated));
if (JSON.stringify(migrated) !== JSON.stringify(newSettings)) {
logger.debug(`Migration '${migration}' has been applied.`, {
label: 'Settings Migrator',
});
}
migrated = newSettings;
} catch (e) {
// we stop jellyseerr if the migration failed
logger.error(
`Error while running migration '${migration}': ${e.message}`,
{
label: 'Settings Migrator',
}
);
logger.error(
'A common cause for this error is a permission issue with your configuration folder, a network issue or a corrupted database.',
{
label: 'Settings Migrator',
}
);
process.exit();
}
}
const settingsAfter = JSON.stringify(migrated);
if (settingsBefore !== settingsAfter) {
// a migration occured
// we check that the new config will be saved
await fs.writeFile(
SETTINGS_PATH,
JSON.stringify(migrated, undefined, ' ')
);
const fileSaved = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
if (JSON.stringify(fileSaved) !== settingsAfter) {
// something went wrong while saving file
throw new Error('Unable to save settings after migration.');
}
} else if (oldBackup) {
// no migration occured
// we save the old backup (to avoid settings.json and settings.old.json being the same)
await fs.writeFile(BACKUP_PATH, oldBackup.toString());
} }
} catch (e) { } 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

@@ -14,7 +14,6 @@ import { ApiError } from '@server/types/error';
import { getHostname } from '@server/utils/getHostname'; import { getHostname } from '@server/utils/getHostname';
import * as EmailValidator from 'email-validator'; import * as EmailValidator from 'email-validator';
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import net from 'net'; import net from 'net';
const authRoutes = Router(); const authRoutes = Router();
@@ -88,7 +87,7 @@ authRoutes.post('/plex', async (req, res, next) => {
}); });
settings.main.mediaServerType = MediaServerType.PLEX; settings.main.mediaServerType = MediaServerType.PLEX;
settings.save(); await settings.save();
startJobs(); startJobs();
await userRepository.save(user); await userRepository.save(user);
@@ -261,8 +260,6 @@ 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 },
@@ -280,11 +277,6 @@ 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;
@@ -307,62 +299,84 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
where: { jellyfinUserId: account.User.Id }, where: { jellyfinUserId: account.User.Id },
}); });
if (!user && !(await userRepository.count())) { const missingAdminUser = !user && !(await userRepository.count());
if (
missingAdminUser ||
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED
) {
// Check if user is admin on jellyfin // Check if user is admin on jellyfin
if (account.User.Policy.IsAdministrator === false) { if (account.User.Policy.IsAdministrator === false) {
throw new ApiError(403, ApiErrorCode.NotAdmin); throw new ApiError(403, ApiErrorCode.NotAdmin);
} }
logger.info( if (
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr', body.serverType !== MediaServerType.JELLYFIN &&
{ body.serverType !== MediaServerType.EMBY
label: 'API', ) {
ip: req.ip, throw new Error('select_server_type');
jellyfinUsername: account.User.Name, }
} settings.main.mediaServerType = body.serverType;
);
// User doesn't exist, and there are no users in the database, we'll create the user if (missingAdminUser) {
// with admin permissions logger.info(
switch (body.serverType) { 'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Jellyseerr',
case MediaServerType.EMBY: {
settings.main.mediaServerType = MediaServerType.EMBY; label: 'API',
user = new User({ ip: req.ip,
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, // User doesn't exist, and there are no users in the database, we'll create the user
avatar: account.User.PrimaryImageTag // with admin permissions
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email || account.User.Name, { user = new User({
default: 'mm', id: 1,
size: 200, email: body.email || account.User.Name,
}), jellyfinUsername: account.User.Name,
userType: UserType.EMBY, jellyfinUserId: account.User.Id,
}); jellyfinDeviceId: deviceId,
break; jellyfinAuthToken: account.AccessToken,
case MediaServerType.JELLYFIN: permissions: Permission.ADMIN,
settings.main.mediaServerType = MediaServerType.JELLYFIN; avatar: `/avatarproxy/${account.User.Id}`,
user = new User({ userType:
email: body.email || account.User.Name, 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, jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id, }
jellyfinDeviceId: deviceId, );
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN, // User alread exist but settings.json is not configured, we'll edit the admin user
avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` user = await userRepository.findOne({
: gravatarUrl(body.email || account.User.Name, { where: { id: 1 },
default: 'mm', });
size: 200, if (!user) {
}), throw new Error('Unable to find admin user to edit');
userType: UserType.JELLYFIN, }
}); user.email = body.email || account.User.Name;
break; user.jellyfinUsername = account.User.Name;
default: user.jellyfinUserId = account.User.Id;
throw new Error('select_server_type'); 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);
} }
// Create an API key on Jellyfin from this admin user // Create an API key on Jellyfin from this admin user
@@ -382,10 +396,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
settings.jellyfin.urlBase = body.urlBase ?? ''; settings.jellyfin.urlBase = body.urlBase ?? '';
settings.jellyfin.useSsl = body.useSsl ?? false; settings.jellyfin.useSsl = body.useSsl ?? false;
settings.jellyfin.apiKey = apiKey; settings.jellyfin.apiKey = apiKey;
settings.save(); await settings.save();
startJobs(); startJobs();
await userRepository.save(user);
} }
// User already exists, let's update their information // User already exists, let's update their information
else if (account.User.Id === user?.jellyfinUserId) { else if (account.User.Id === user?.jellyfinUserId) {
@@ -405,15 +417,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUsername: account.User.Name, jellyfinUsername: account.User.Name,
} }
); );
// Update the users avatar with their jellyfin profile pic (incase it changed) user.avatar = `/avatarproxy/${account.User.Id}`;
if (account.User.PrimaryImageTag) {
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
} else {
user.avatar = gravatarUrl(user.email || account.User.Name, {
default: 'mm',
size: 200,
});
}
user.jellyfinUsername = account.User.Name; user.jellyfinUsername = account.User.Name;
if (user.username === account.User.Name) { if (user.username === account.User.Name) {
@@ -451,17 +455,13 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUserId: account.User.Id, jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId, jellyfinDeviceId: deviceId,
permissions: settings.main.defaultPermissions, permissions: settings.main.defaultPermissions,
avatar: account.User.PrimaryImageTag avatar: `/avatarproxy/${account.User.Id}`,
? `${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
: UserType.EMBY, : UserType.EMBY,
}); });
//initialize Jellyfin/Emby users with local login //initialize Jellyfin/Emby users with local login
const passedExplicitPassword = body.password && body.password.length > 0; const passedExplicitPassword = body.password && body.password.length > 0;
if (passedExplicitPassword) { if (passedExplicitPassword) {

View File

@@ -0,0 +1,86 @@
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import ImageProxy from '@server/lib/imageproxy';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { getAppVersion } from '@server/utils/appVersion';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
const router = Router();
let _avatarImageProxy: ImageProxy | null = null;
async function initAvatarImageProxy() {
if (!_avatarImageProxy) {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
where: { id: 1 },
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' },
});
const deviceId = admin?.jellyfinDeviceId;
const authToken = getSettings().jellyfin.apiKey;
_avatarImageProxy = new ImageProxy('avatar', '', {
headers: {
'X-Emby-Authorization': `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`,
},
});
}
return _avatarImageProxy;
}
router.get('/:jellyfinUserId', async (req, res) => {
try {
if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) {
const mediaServerType = getSettings().main.mediaServerType;
throw new Error(
`Provided URL is not ${
mediaServerType === MediaServerType.JELLYFIN
? 'a Jellyfin'
: 'an Emby'
} avatar.`
);
}
const avatarImageCache = await initAvatarImageProxy();
const user = await getRepository(User).findOne({
where: { jellyfinUserId: req.params.jellyfinUserId },
});
const fallbackUrl = gravatarUrl(user?.email || 'none', {
default: 'mm',
size: 200,
});
const jellyfinAvatarUrl = `${getHostname()}/UserImage?UserId=${
req.params.jellyfinUserId
}`;
let imageData = await avatarImageCache.getImage(
jellyfinAvatarUrl,
fallbackUrl
);
if (imageData.meta.extension === 'json') {
// this is a 404
imageData = await avatarImageCache.getImage(fallbackUrl);
}
res.writeHead(200, {
'Content-Type': `image/${imageData.meta.extension}`,
'Content-Length': imageData.imageBuffer.length,
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
'OS-Cache-Key': imageData.meta.cacheKey,
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
});
res.end(imageData.imageBuffer);
} catch (e) {
logger.error('Failed to proxy avatar image', {
errorMessage: e.message,
});
}
});
export default router;

View File

@@ -5,6 +5,7 @@ import { Router } from 'express';
const router = Router(); const router = Router();
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', { const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
rateLimitOptions: { rateLimitOptions: {
maxRequests: 20,
maxRPS: 50, maxRPS: 50,
}, },
}); });

View File

@@ -17,7 +17,11 @@ 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 { appDataPath, appDataStatus } from '@server/utils/appDataVolume'; import {
appDataPath,
appDataPermissions,
appDataStatus,
} from '@server/utils/appDataVolume';
import { getAppVersion, getCommitTag } from '@server/utils/appVersion'; import { getAppVersion, getCommitTag } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag'; import restartFlag from '@server/utils/restartFlag';
import { isPerson } from '@server/utils/typeHelpers'; import { isPerson } from '@server/utils/typeHelpers';
@@ -93,6 +97,7 @@ router.get('/status/appdata', (_req, res) => {
return res.status(200).json({ return res.status(200).json({
appData: appDataStatus(), appData: appDataStatus(),
appDataPath: appDataPath(), appDataPath: appDataPath(),
appDataPermissions: appDataPermissions(),
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -516,12 +516,6 @@ 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();
@@ -545,12 +539,7 @@ router.post(
).toString('base64'), ).toString('base64'),
email: jellyfinUser?.Name, email: jellyfinUser?.Name,
permissions: settings.main.defaultPermissions, permissions: settings.main.defaultPermissions,
avatar: jellyfinUser?.PrimaryImageTag avatar: `/avatarproxy/${jellyfinUser?.Id}`,
? `${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 { existsSync } from 'fs'; import { accessSync, existsSync } from 'fs';
import path from 'path'; import path from 'path';
const CONFIG_PATH = process.env.CONFIG_DIRECTORY const CONFIG_PATH = process.env.CONFIG_DIRECTORY
@@ -14,3 +14,12 @@ export const appDataStatus = (): boolean => {
export const appDataPath = (): string => { export const appDataPath = (): string => {
return CONFIG_PATH; return CONFIG_PATH;
}; };
export const appDataPermissions = (): boolean => {
try {
accessSync(CONFIG_PATH);
return true;
} catch (err) {
return false;
}
};

View File

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

View File

@@ -1,68 +0,0 @@
export type RateLimitOptions = {
maxRPS: number;
id?: string;
};
type RateLimiteState<T extends (...args: Parameters<T>) => Promise<U>, U> = {
queue: {
args: Parameters<T>;
resolve: (value: U) => void;
reject: (reason?: unknown) => void;
}[];
lastTimestamps: number[];
timeout: ReturnType<typeof setTimeout>;
};
const rateLimitById: Record<string, unknown> = {};
/**
* Add a rate limit to a function so it doesn't exceed a maximum number of requests per second. Function calls exceeding the rate will be delayed.
* @param fn The function to rate limit
* @param options.maxRPS Maximum number of Requests Per Second
* @param options.id An ID to share between rate limits, so it uses the same request queue.
* @returns The function with a rate limit
*/
export default function rateLimit<
T extends (...args: Parameters<T>) => Promise<U>,
U
>(fn: T, options: RateLimitOptions): (...args: Parameters<T>) => Promise<U> {
const state: RateLimiteState<T, U> = (rateLimitById[
options.id || ''
] as RateLimiteState<T, U>) || { queue: [], lastTimestamps: [] };
if (options.id) {
rateLimitById[options.id] = state;
}
const processQueue = () => {
// remove old timestamps
state.lastTimestamps = state.lastTimestamps.filter(
(timestamp) => Date.now() - timestamp < 1000
);
if (state.lastTimestamps.length < options.maxRPS) {
// process requests if RPS not exceeded
const item = state.queue.shift();
if (!item) return;
state.lastTimestamps.push(Date.now());
const { args, resolve, reject } = item;
fn(...args)
.then(resolve)
.catch(reject);
processQueue();
} else {
// rerun once the oldest item in queue is older than 1s
if (state.timeout) clearTimeout(state.timeout);
state.timeout = setTimeout(
processQueue,
1000 - (Date.now() - state.lastTimestamps[0])
);
}
};
return (...args: Parameters<T>): Promise<U> => {
return new Promise<U>((resolve, reject) => {
state.queue.push({ args, resolve, reject });
processQueue();
});
};
}

View File

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

View File

@@ -23,6 +23,7 @@ import type {
} from '@server/interfaces/api/blacklistInterfaces'; } from '@server/interfaces/api/blacklistInterfaces';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import type { ChangeEvent } from 'react'; import type { ChangeEvent } from 'react';
@@ -238,11 +239,8 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
const removeFromBlacklist = async (tmdbId: number, title?: string) => { const removeFromBlacklist = async (tmdbId: number, title?: string) => {
setIsUpdating(true); setIsUpdating(true);
const res = await fetch('/api/v1/blacklist/' + tmdbId, { try {
method: 'DELETE', await axios.delete('/api/v1/blacklist/' + tmdbId);
});
if (res.status === 204) {
addToast( addToast(
<span> <span>
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, { {intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
@@ -252,7 +250,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
</span>, </span>,
{ appearance: 'success', autoDismiss: true } { appearance: 'success', autoDismiss: true }
); );
} else { } catch {
addToast(intl.formatMessage(globalMessages.blacklistError), { addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error', appearance: 'error',
autoDismiss: true, autoDismiss: true,
@@ -268,6 +266,7 @@ 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' }}
@@ -293,6 +292,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105" 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}`
@@ -355,6 +355,7 @@ 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

@@ -6,6 +6,7 @@ import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { CalendarIcon, TrashIcon, UserIcon } from '@heroicons/react/24/solid'; import { CalendarIcon, TrashIcon, UserIcon } from '@heroicons/react/24/solid';
import type { Blacklist } from '@server/entity/Blacklist'; import type { Blacklist } from '@server/entity/Blacklist';
import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -35,11 +36,8 @@ const BlacklistBlock = ({
const removeFromBlacklist = async (tmdbId: number, title?: string) => { const removeFromBlacklist = async (tmdbId: number, title?: string) => {
setIsUpdating(true); setIsUpdating(true);
const res = await fetch('/api/v1/blacklist/' + tmdbId, { try {
method: 'DELETE', await axios.delete('/api/v1/blacklist/' + tmdbId);
});
if (res.status === 204) {
addToast( addToast(
<span> <span>
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, { {intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
@@ -49,7 +47,7 @@ const BlacklistBlock = ({
</span>, </span>,
{ appearance: 'success', autoDismiss: true } { appearance: 'success', autoDismiss: true }
); );
} else { } catch {
addToast(intl.formatMessage(globalMessages.blacklistError), { addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error', appearance: 'error',
autoDismiss: true, autoDismiss: true,

View File

@@ -198,6 +198,7 @@ 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' }}
@@ -228,6 +229,7 @@ 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,21 +4,31 @@ 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, ...props }: ImageProps) => { const CachedImage = ({ src, type, ...props }: CachedImageProps) => {
const { currentSettings } = useSettings(); const { currentSettings } = useSettings();
let imageUrl = src; let imageUrl: string;
if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) { if (type === 'tmdb') {
const parsedUrl = new URL(imageUrl); // tmdb stuff
imageUrl =
if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) { currentSettings.cacheImages && !src.startsWith('/')
imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy'); ? src.replace(/^https:\/\/image\.tmdb\.org\//, '/imageproxy/')
} : src;
} else if (type === 'avatar') {
// jellyfin avatar (if any)
imageUrl = src;
} else {
return null;
} }
return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />; return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />;

View File

@@ -61,6 +61,7 @@ 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,6 +123,7 @@ 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,6 +33,7 @@ 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

@@ -14,6 +14,7 @@ import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider'; import type DiscoverSlider from '@server/entity/DiscoverSlider';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import type { Keyword, ProductionCompany } from '@server/models/common'; import type { Keyword, ProductionCompany } from '@server/models/common';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -76,9 +77,11 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
const keywords = await Promise.all( const keywords = await Promise.all(
slider.data.split(',').map(async (keywordId) => { slider.data.split(',').map(async (keywordId) => {
const res = await fetch(`/api/v1/keyword/${keywordId}`); const keyword = await axios.get<Keyword>(
const keyword: Keyword = await res.json(); `/api/v1/keyword/${keywordId}`
return keyword; );
return keyword.data;
}) })
); );
@@ -95,13 +98,15 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
return; return;
} }
const res = await fetch( const response = await axios.get<TmdbGenre[]>(
`/api/v1/genres/${ `/api/v1/genres/${
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE ? 'movie' : 'tv' slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE ? 'movie' : 'tv'
}` }`
); );
const genres: TmdbGenre[] = await res.json();
const genre = genres.find((genre) => genre.id === Number(slider.data)); const genre = response.data.find(
(genre) => genre.id === Number(slider.data)
);
setDefaultDataValue([ setDefaultDataValue([
{ {
@@ -116,8 +121,11 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
return; return;
} }
const res = await fetch(`/api/v1/studio/${slider.data}`); const response = await axios.get<ProductionCompany>(
const studio: ProductionCompany = await res.json(); `/api/v1/studio/${slider.data}`
);
const studio = response.data;
setDefaultDataValue([ setDefaultDataValue([
{ {
@@ -160,17 +168,16 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
); );
const loadKeywordOptions = async (inputValue: string) => { const loadKeywordOptions = async (inputValue: string) => {
const res = await fetch( const results = await axios.get<TmdbKeywordSearchResponse>(
`/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`, '/api/v1/search/keyword',
{ {
headers: { params: {
'Content-Type': 'application/json', query: encodeURIExtraParams(inputValue),
}, },
} }
); );
const results: TmdbKeywordSearchResponse = await res.json();
return results.results.map((result) => ({ return results.data.results.map((result) => ({
label: result.name, label: result.name,
value: result.id, value: result.id,
})); }));
@@ -181,37 +188,38 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
return []; return [];
} }
const res = await fetch( const results = await axios.get<TmdbCompanySearchResponse>(
`/api/v1/search/company?query=${encodeURIExtraParams(inputValue)}`, '/api/v1/search/company',
{ {
headers: { params: {
'Content-Type': 'application/json', query: encodeURIExtraParams(inputValue),
}, },
} }
); );
const results: TmdbCompanySearchResponse = await res.json();
return results.results.map((result) => ({ return results.data.results.map((result) => ({
label: result.name, label: result.name,
value: result.id, value: result.id,
})); }));
}; };
const loadMovieGenreOptions = async () => { const loadMovieGenreOptions = async () => {
const res = await fetch('/api/v1/discover/genreslider/movie'); const results = await axios.get<GenreSliderItem[]>(
const results: GenreSliderItem[] = await res.json(); '/api/v1/discover/genreslider/movie'
);
return results.map((result) => ({ return results.data.map((result) => ({
label: result.name, label: result.name,
value: result.id, value: result.id,
})); }));
}; };
const loadTvGenreOptions = async () => { const loadTvGenreOptions = async () => {
const res = await fetch('/api/v1/discover/genreslider/tv'); const results = await axios.get<GenreSliderItem[]>(
const results: GenreSliderItem[] = await res.json(); '/api/v1/discover/genreslider/tv'
);
return results.map((result) => ({ return results.data.map((result) => ({
label: result.name, label: result.name,
value: result.id, value: result.id,
})); }));
@@ -306,31 +314,17 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
onSubmit={async (values, { resetForm }) => { onSubmit={async (values, { resetForm }) => {
try { try {
if (slider) { if (slider) {
const res = await fetch(`/api/v1/settings/discover/${slider.id}`, { await axios.put(`/api/v1/settings/discover/${slider.id}`, {
method: 'PUT', type: Number(values.sliderType),
headers: { title: values.title,
'Content-Type': 'application/json', data: values.data,
},
body: JSON.stringify({
type: Number(values.sliderType),
title: values.title,
data: values.data,
}),
}); });
if (!res.ok) throw new Error();
} else { } else {
const res = await fetch('/api/v1/settings/discover/add', { await axios.post('/api/v1/settings/discover/add', {
method: 'POST', type: Number(values.sliderType),
headers: { title: values.title,
'Content-Type': 'application/json', data: values.data,
},
body: JSON.stringify({
type: Number(values.sliderType),
title: values.title,
data: values.data,
}),
}); });
if (!res.ok) throw new Error();
} }
addToast( addToast(

View File

@@ -20,6 +20,7 @@ import {
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import { DiscoverSliderType } from '@server/constants/discover'; import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider'; import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { useDrag, useDrop } from 'react-aria'; import { useDrag, useDrop } from 'react-aria';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -77,10 +78,7 @@ const DiscoverSliderEdit = ({
const deleteSlider = async () => { const deleteSlider = async () => {
try { try {
const res = await fetch(`/api/v1/settings/discover/${slider.id}`, { await axios.delete(`/api/v1/settings/discover/${slider.id}`);
method: 'DELETE',
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.deletesuccess), { addToast(intl.formatMessage(messages.deletesuccess), {
appearance: 'success', appearance: 'success',
autoDismiss: true, autoDismiss: true,

View File

@@ -28,6 +28,7 @@ import {
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import { DiscoverSliderType } from '@server/constants/discover'; import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider'; import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
@@ -75,14 +76,7 @@ const Discover = () => {
const updateSliders = async () => { const updateSliders = async () => {
try { try {
const res = await fetch('/api/v1/settings/discover', { await axios.post('/api/v1/settings/discover', sliders);
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(sliders),
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.updatesuccess), { addToast(intl.formatMessage(messages.updatesuccess), {
appearance: 'success', appearance: 'success',
@@ -100,10 +94,7 @@ const Discover = () => {
const resetSliders = async () => { const resetSliders = async () => {
try { try {
const res = await fetch('/api/v1/settings/discover/reset', { await axios.get('/api/v1/settings/discover/reset');
method: 'GET',
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.resetsuccess), { addToast(intl.formatMessage(messages.resetsuccess), {
appearance: 'success', appearance: 'success',

View File

@@ -36,6 +36,7 @@ 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

@@ -1,12 +1,13 @@
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import Modal from '@app/components/Common/Modal'; import Modal from '@app/components/Common/Modal';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { Menu, Transition } from '@headlessui/react'; import { Menu, Transition } from '@headlessui/react';
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import type { default as IssueCommentType } from '@server/entity/IssueComment'; import type { default as IssueCommentType } from '@server/entity/IssueComment';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { FormattedRelativeTime, useIntl } from 'react-intl'; import { FormattedRelativeTime, useIntl } from 'react-intl';
@@ -48,10 +49,7 @@ const IssueComment = ({
const deleteComment = async () => { const deleteComment = async () => {
try { try {
const res = await fetch(`/api/v1/issueComment/${comment.id}`, { await axios.delete(`/api/v1/issueComment/${comment.id}`);
method: 'DELETE',
});
if (!res.ok) throw new Error();
} catch (e) { } catch (e) {
// something went wrong deleting the comment // something went wrong deleting the comment
} finally { } finally {
@@ -88,7 +86,8 @@ const IssueComment = ({
</Modal> </Modal>
</Transition> </Transition>
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}> <Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
<Image <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"
@@ -177,17 +176,9 @@ const IssueComment = ({
<Formik <Formik
initialValues={{ newMessage: comment.message }} initialValues={{ newMessage: comment.message }}
onSubmit={async (values) => { onSubmit={async (values) => {
const res = await fetch( await axios.put(`/api/v1/issueComment/${comment.id}`, {
`/api/v1/issueComment/${comment.id}`, message: values.newMessage,
{ });
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: values.newMessage }),
}
);
if (!res.ok) throw new Error();
if (onUpdate) { if (onUpdate) {
onUpdate(); onUpdate();

View File

@@ -11,7 +11,7 @@ import useDeepLinks from '@app/hooks/useDeepLinks';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import ErrorPage from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { import {
@@ -27,8 +27,8 @@ import { MediaServerType } from '@server/constants/server';
import type Issue from '@server/entity/Issue'; import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
@@ -113,7 +113,7 @@ const IssueDetails = () => {
} }
if (!data || !issueData) { if (!data || !issueData) {
return <ErrorPage statusCode={404} />; return <Error statusCode={404} />;
} }
const belongsToUser = issueData.createdBy.id === currentUser?.id; const belongsToUser = issueData.createdBy.id === currentUser?.id;
@@ -122,14 +122,9 @@ const IssueDetails = () => {
const editFirstComment = async (newMessage: string) => { const editFirstComment = async (newMessage: string) => {
try { try {
const res = await fetch(`/api/v1/issueComment/${firstComment.id}`, { await axios.put(`/api/v1/issueComment/${firstComment.id}`, {
method: 'PUT', message: newMessage,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: newMessage }),
}); });
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.toasteditdescriptionsuccess), { addToast(intl.formatMessage(messages.toasteditdescriptionsuccess), {
appearance: 'success', appearance: 'success',
@@ -146,10 +141,7 @@ const IssueDetails = () => {
const updateIssueStatus = async (newStatus: 'open' | 'resolved') => { const updateIssueStatus = async (newStatus: 'open' | 'resolved') => {
try { try {
const res = await fetch(`/api/v1/issue/${issueData.id}/${newStatus}`, { await axios.post(`/api/v1/issue/${issueData.id}/${newStatus}`);
method: 'POST',
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.toaststatusupdated), { addToast(intl.formatMessage(messages.toaststatusupdated), {
appearance: 'success', appearance: 'success',
@@ -166,10 +158,7 @@ const IssueDetails = () => {
const deleteIssue = async () => { const deleteIssue = async () => {
try { try {
const res = await fetch(`/api/v1/issue/${issueData.id}`, { await axios.delete(`/api/v1/issue/${issueData.id}`);
method: 'DELETE',
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.toastissuedeleted), { addToast(intl.formatMessage(messages.toastissuedeleted), {
appearance: 'success', appearance: 'success',
@@ -218,6 +207,7 @@ 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,6 +226,7 @@ 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}`
@@ -287,10 +278,11 @@ 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"
> >
<Image <CachedImage
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" 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"
width={20} width={20}
height={20} height={20}
/> />
@@ -500,17 +492,9 @@ const IssueDetails = () => {
}} }}
validationSchema={CommentSchema} validationSchema={CommentSchema}
onSubmit={async (values, { resetForm }) => { onSubmit={async (values, { resetForm }) => {
const res = await fetch( await axios.post(`/api/v1/issue/${issueData?.id}/comment`, {
`/api/v1/issue/${issueData?.id}/comment`, message: values.message,
{ });
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: values.message }),
}
);
if (!res.ok) throw new Error();
revalidateIssue(); revalidateIssue();
resetForm(); resetForm();
}} }}

View File

@@ -11,7 +11,6 @@ import { MediaType } from '@server/constants/media';
import type Issue from '@server/entity/Issue'; import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { FormattedRelativeTime, useIntl } from 'react-intl'; import { FormattedRelativeTime, useIntl } from 'react-intl';
@@ -113,6 +112,7 @@ 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,6 +138,7 @@ const IssueItem = ({ issue }: IssueItemProps) => {
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105" 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}`
@@ -226,7 +227,8 @@ const IssueItem = ({ issue }: IssueItemProps) => {
href={`/users/${issue.createdBy.id}`} href={`/users/${issue.createdBy.id}`}
className="group flex items-center truncate" className="group flex items-center truncate"
> >
<Image <CachedImage
type="avatar"
src={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"

View File

@@ -11,6 +11,7 @@ import { MediaStatus } from '@server/constants/media';
import type Issue from '@server/entity/Issue'; import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import { Field, Formik } from 'formik'; import { Field, Formik } from 'formik';
import Link from 'next/link'; import Link from 'next/link';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -100,22 +101,14 @@ const CreateIssueModal = ({
validationSchema={CreateIssueModalSchema} validationSchema={CreateIssueModalSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
const res = await fetch('/api/v1/issue', { const newIssue = await axios.post<Issue>('/api/v1/issue', {
method: 'POST', issueType: values.selectedIssue.issueType,
headers: { message: values.message,
'Content-Type': 'application/json', mediaId: data?.mediaInfo?.id,
}, problemSeason: values.problemSeason,
body: JSON.stringify({ problemEpisode:
issueType: values.selectedIssue.issueType, values.problemSeason > 0 ? values.problemEpisode : 0,
message: values.message,
mediaId: data?.mediaInfo?.id,
problemSeason: values.problemSeason,
problemEpisode:
values.problemSeason > 0 ? values.problemEpisode : 0,
}),
}); });
if (!res.ok) throw new Error();
const newIssue: Issue = await res.json();
if (data) { if (data) {
addToast( addToast(
@@ -126,7 +119,7 @@ const CreateIssueModal = ({
strong: (msg: React.ReactNode) => <strong>{msg}</strong>, strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})} })}
</div> </div>
<Link href={`/issues/${newIssue.id}`} legacyBehavior> <Link href={`/issues/${newIssue.data.id}`} legacyBehavior>
<Button as="a" className="mt-4"> <Button as="a" className="mt-4">
<span>{intl.formatMessage(messages.toastviewissue)}</span> <span>{intl.formatMessage(messages.toastviewissue)}</span>
<ArrowRightCircleIcon /> <ArrowRightCircleIcon />

View File

@@ -7,6 +7,7 @@ import {
CogIcon, CogIcon,
EllipsisHorizontalIcon, EllipsisHorizontalIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
EyeSlashIcon,
FilmIcon, FilmIcon,
SparklesIcon, SparklesIcon,
TvIcon, TvIcon,
@@ -16,6 +17,7 @@ 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,
@@ -84,6 +86,18 @@ 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

@@ -1,3 +1,4 @@
import CachedImage from '@app/components/Common/CachedImage';
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay'; import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
@@ -7,7 +8,7 @@ import {
ClockIcon, ClockIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { CogIcon, UserIcon } from '@heroicons/react/24/solid'; import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
import Image from 'next/image'; import axios from 'axios';
import type { LinkProps } from 'next/link'; import type { LinkProps } from 'next/link';
import Link from 'next/link'; import Link from 'next/link';
import { forwardRef, Fragment } from 'react'; import { forwardRef, Fragment } from 'react';
@@ -38,13 +39,9 @@ const UserDropdown = () => {
const { user, revalidate } = useUser(); const { user, revalidate } = useUser();
const logout = async () => { const logout = async () => {
const res = await fetch('/api/v1/auth/logout', { const response = await axios.post('/api/v1/auth/logout');
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (data?.status === 'ok') { if (response.data?.status === 'ok') {
revalidate(); revalidate();
} }
}; };
@@ -56,9 +53,10 @@ const UserDropdown = () => {
className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500" className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500"
data-testid="user-menu" data-testid="user-menu"
> >
<Image <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?.avatar || ''} src={user ? user.avatar : ''}
alt="" alt=""
width={40} width={40}
height={40} height={40}
@@ -79,9 +77,10 @@ const UserDropdown = () => {
<div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur"> <div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur">
<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">
<Image <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?.avatar || ''} src={user ? user.avatar : ''}
alt="" alt=""
width={40} width={40}
height={40} height={40}

View File

@@ -2,6 +2,7 @@ import Modal from '@app/components/Common/Modal';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import axios from 'axios';
import { Field, Formik } from 'formik'; import { Field, Formik } from 'formik';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import * as Yup from 'yup'; import * as Yup from 'yup';
@@ -57,18 +58,11 @@ const AddEmailModal: React.FC<AddEmailModalProps> = ({
validationSchema={EmailSettingsSchema} validationSchema={EmailSettingsSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
const res = await fetch('/api/v1/auth/jellyfin', { await axios.post('/api/v1/auth/jellyfin', {
method: 'POST', username: username,
headers: { password: password,
'Content-Type': 'application/json', email: values.email,
},
body: JSON.stringify({
username: username,
password: password,
email: values.email,
}),
}); });
if (!res.ok) throw new Error();
onSave(); onSave();
} catch (e) { } catch (e) {

View File

@@ -5,6 +5,7 @@ import defineMessages from '@app/utils/defineMessages';
import { InformationCircleIcon } from '@heroicons/react/24/solid'; import { InformationCircleIcon } from '@heroicons/react/24/solid';
import { ApiErrorCode } from '@server/constants/error'; import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType, ServerType } from '@server/constants/server'; import { MediaServerType, ServerType } from '@server/constants/server';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { FormattedMessage, useIntl } from 'react-intl'; import { FormattedMessage, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
@@ -113,24 +114,16 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
// if (serverType !== 'Jellyfin' && serverType !== 'Emby') { // if (serverType !== 'Jellyfin' && serverType !== 'Emby') {
// throw new Error('Invalid serverType'); // You can customize the error message // throw new Error('Invalid serverType'); // You can customize the error message
// } // }
await axios.post('/api/v1/auth/jellyfin', {
const res = await fetch('/api/v1/auth/jellyfin', { username: values.username,
method: 'POST', password: values.password,
headers: { hostname: values.hostname,
'Content-Type': 'application/json', port: values.port,
}, useSsl: values.useSsl,
body: JSON.stringify({ urlBase: values.urlBase,
username: values.username, email: values.email,
password: values.password, serverType: serverType,
hostname: values.hostname,
port: values.port,
useSsl: values.useSsl,
urlBase: values.urlBase,
email: values.email,
serverType: serverType,
}),
}); });
if (!res.ok) throw new Error(res.statusText, { cause: res });
} catch (e) { } catch (e) {
let errorData; let errorData;
try { try {
@@ -370,18 +363,11 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
validationSchema={LoginSchema} validationSchema={LoginSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
const res = await fetch('/api/v1/auth/jellyfin', { await axios.post('/api/v1/auth/jellyfin', {
method: 'POST', username: values.username,
headers: { password: values.password,
'Content-Type': 'application/json', email: values.username,
},
body: JSON.stringify({
username: values.username,
password: values.password,
email: values.username,
}),
}); });
if (!res.ok) throw new Error();
} catch (e) { } catch (e) {
toasts.addToast( toasts.addToast(
intl.formatMessage( intl.formatMessage(

View File

@@ -6,6 +6,7 @@ import {
ArrowLeftOnRectangleIcon, ArrowLeftOnRectangleIcon,
LifebuoyIcon, LifebuoyIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
@@ -55,17 +56,10 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
validationSchema={LoginSchema} validationSchema={LoginSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
const res = await fetch('/api/v1/auth/local', { await axios.post('/api/v1/auth/local', {
method: 'POST', email: values.email,
headers: { password: values.password,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: values.email,
password: values.password,
}),
}); });
if (!res.ok) throw new Error();
} catch (e) { } catch (e) {
setLoginError(intl.formatMessage(messages.loginerror)); setLoginError(intl.formatMessage(messages.loginerror));
} finally { } finally {

View File

@@ -10,6 +10,7 @@ import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { XCircleIcon } from '@heroicons/react/24/solid'; import { XCircleIcon } from '@heroicons/react/24/solid';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import axios from 'axios';
import { useRouter } from 'next/dist/client/router'; import { useRouter } from 'next/dist/client/router';
import Image from 'next/image'; import Image from 'next/image';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -41,17 +42,9 @@ const Login = () => {
const login = async () => { const login = async () => {
setProcessing(true); setProcessing(true);
try { try {
const res = await fetch('/api/v1/auth/plex', { const response = await axios.post('/api/v1/auth/plex', { authToken });
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ authToken }),
});
if (!res.ok) throw new Error(res.statusText, { cause: res });
const data = await res.json();
if (data?.id) { if (response.data?.id) {
revalidate(); revalidate();
} }
} catch (e) { } catch (e) {

View File

@@ -1,5 +1,6 @@
import BlacklistBlock from '@app/components/BlacklistBlock'; import BlacklistBlock from '@app/components/BlacklistBlock';
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import ConfirmButton from '@app/components/Common/ConfirmButton'; import ConfirmButton from '@app/components/Common/ConfirmButton';
import SlideOver from '@app/components/Common/SlideOver'; import SlideOver from '@app/components/Common/SlideOver';
import Tooltip from '@app/components/Common/Tooltip'; import Tooltip from '@app/components/Common/Tooltip';
@@ -27,7 +28,7 @@ import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfa
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import Image from 'next/image'; import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
@@ -111,10 +112,7 @@ const ManageSlideOver = ({
const deleteMedia = async () => { const deleteMedia = async () => {
if (data.mediaInfo) { if (data.mediaInfo) {
const res = await fetch(`/api/v1/media/${data.mediaInfo.id}`, { await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
method: 'DELETE',
});
if (!res.ok) throw new Error();
revalidate(); revalidate();
onClose(); onClose();
} }
@@ -122,16 +120,8 @@ const ManageSlideOver = ({
const deleteMediaFile = async () => { const deleteMediaFile = async () => {
if (data.mediaInfo) { if (data.mediaInfo) {
const res1 = await fetch(`/api/v1/media/${data.mediaInfo.id}/file`, { await axios.delete(`/api/v1/media/${data.mediaInfo.id}/file`);
method: 'DELETE', await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
});
if (!res1.ok) throw new Error();
const res2 = await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
method: 'DELETE',
});
if (!res2.ok) throw new Error();
revalidate(); revalidate();
onClose(); onClose();
} }
@@ -160,16 +150,9 @@ const ManageSlideOver = ({
const markAvailable = async (is4k = false) => { const markAvailable = async (is4k = false) => {
if (data.mediaInfo) { if (data.mediaInfo) {
const res = await fetch(`/api/v1/media/${data.mediaInfo?.id}/available`, { await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, {
method: 'POST', is4k,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
is4k,
}),
}); });
if (!res.ok) throw new Error();
revalidate(); revalidate();
} }
}; };
@@ -368,7 +351,8 @@ const ManageSlideOver = ({
key={`watch-user-${user.id}`} key={`watch-user-${user.id}`}
content={user.displayName} content={user.displayName}
> >
<Image <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"
@@ -529,7 +513,8 @@ const ManageSlideOver = ({
key={`watch-user-${user.id}`} key={`watch-user-${user.id}`}
content={user.displayName} content={user.displayName}
> >
<Image <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

@@ -52,6 +52,7 @@ import { IssueStatus } from '@server/constants/issue';
import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import type { MovieDetails as MovieDetailsType } from '@server/models/Movie'; import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
import axios from 'axios';
import { countries } from 'country-flag-icons'; import { countries } from 'country-flag-icons';
import 'country-flag-icons/3x2/flags.css'; import 'country-flag-icons/3x2/flags.css';
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
@@ -313,20 +314,25 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
const onClickWatchlistBtn = async (): Promise<void> => { const onClickWatchlistBtn = async (): Promise<void> => {
setIsUpdating(true); setIsUpdating(true);
const res = await fetch('/api/v1/watchlist', { try {
method: 'POST', const watchlist = await axios.post('/api/v1/watchlist', {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
tmdbId: movie?.id, tmdbId: movie?.id,
mediaType: MediaType.MOVIE, mediaType: MediaType.MOVIE,
title: movie?.title, title: movie?.title,
}), });
});
if (!res.ok) { if (watchlist.data) {
addToast(
<span>
{intl.formatMessage(messages.watchlistSuccess, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
}
} catch {
addToast(intl.formatMessage(messages.watchlistError), { addToast(intl.formatMessage(messages.watchlistError), {
appearance: 'error', appearance: 'error',
autoDismiss: true, autoDismiss: true,
@@ -336,20 +342,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
return; return;
} }
const data = await res.json();
if (data) {
addToast(
<span>
{intl.formatMessage(messages.watchlistSuccess, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
}
setIsUpdating(false); setIsUpdating(false);
setToggleWatchlist((prevState) => !prevState); setToggleWatchlist((prevState) => !prevState);
}; };
@@ -357,22 +349,17 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
const onClickDeleteWatchlistBtn = async (): Promise<void> => { const onClickDeleteWatchlistBtn = async (): Promise<void> => {
setIsUpdating(true); setIsUpdating(true);
try { try {
const res = await fetch(`/api/v1/watchlist/${movie?.id}`, { await axios.delete(`/api/v1/watchlist/${movie?.id}`);
method: 'DELETE',
});
if (!res.ok) throw new Error();
if (res.status === 204) { addToast(
addToast( <span>
<span> {intl.formatMessage(messages.watchlistDeleted, {
{intl.formatMessage(messages.watchlistDeleted, { title: movie?.title,
title: movie?.title, strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>, })}
})} </span>,
</span>, { appearance: 'info', autoDismiss: true }
{ appearance: 'info', autoDismiss: true } );
);
}
} catch (e) { } catch (e) {
addToast(intl.formatMessage(messages.watchlistError), { addToast(intl.formatMessage(messages.watchlistError), {
appearance: 'error', appearance: 'error',
@@ -387,21 +374,21 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
const onClickHideItemBtn = async (): Promise<void> => { const onClickHideItemBtn = async (): Promise<void> => {
setIsBlacklistUpdating(true); setIsBlacklistUpdating(true);
const res = await fetch('/api/v1/blacklist', { try {
method: 'POST', await axios.post('/api/v1/blacklist', {
headers: { method: 'POST',
Accept: 'application/json', headers: {
'Content-Type': 'application/json', Accept: 'application/json',
}, 'Content-Type': 'application/json',
body: JSON.stringify({ },
tmdbId: movie?.id, body: JSON.stringify({
mediaType: 'movie', tmdbId: movie?.id,
title: movie?.title, mediaType: 'movie',
user: user?.id, title: movie?.title,
}), user: user?.id,
}); }),
});
if (res.status === 201) {
addToast( addToast(
<span> <span>
{intl.formatMessage(globalMessages.blacklistSuccess, { {intl.formatMessage(globalMessages.blacklistSuccess, {
@@ -413,21 +400,23 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
); );
revalidate(); revalidate();
} else if (res.status === 412) { } catch (e) {
addToast( if (e?.response?.status === 412) {
<span> addToast(
{intl.formatMessage(globalMessages.blacklistDuplicateError, { <span>
title: movie?.title, {intl.formatMessage(globalMessages.blacklistDuplicateError, {
strong: (msg: React.ReactNode) => <strong>{msg}</strong>, title: movie?.title,
})} strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
</span>, })}
{ appearance: 'info', autoDismiss: true } </span>,
); { appearance: 'info', autoDismiss: true }
} else { );
addToast(intl.formatMessage(globalMessages.blacklistError), { } else {
appearance: 'error', addToast(intl.formatMessage(globalMessages.blacklistError), {
autoDismiss: true, appearance: 'error',
}); autoDismiss: true,
});
}
} }
setIsBlacklistUpdating(false); setIsBlacklistUpdating(false);
@@ -448,6 +437,7 @@ 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' }}
@@ -494,6 +484,7 @@ 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}`
@@ -741,6 +732,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
<div className="group relative z-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-lg bg-gray-800 bg-cover bg-center shadow-md ring-1 ring-gray-700 transition duration-300 hover:scale-105 hover:ring-gray-500"> <div className="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,6 +51,7 @@ 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,6 +227,7 @@ 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

@@ -17,6 +17,7 @@ import {
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import { MediaRequestStatus } from '@server/constants/media'; import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -52,10 +53,7 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
const updateRequest = async (type: 'approve' | 'decline'): Promise<void> => { const updateRequest = async (type: 'approve' | 'decline'): Promise<void> => {
setIsUpdating(true); setIsUpdating(true);
const res = await fetch(`/api/v1/request/${request.id}/${type}`, { await axios.post(`/api/v1/request/${request.id}/${type}`);
method: 'POST',
});
if (!res.ok) throw new Error();
if (onUpdate) { if (onUpdate) {
onUpdate(); onUpdate();
@@ -65,10 +63,7 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
const deleteRequest = async () => { const deleteRequest = async () => {
setIsUpdating(true); setIsUpdating(true);
const res = await fetch(`/api/v1/request/${request.id}`, { await axios.delete(`/api/v1/request/${request.id}`);
method: 'DELETE',
});
if (!res.ok) throw new Error();
if (onUpdate) { if (onUpdate) {
onUpdate(); onUpdate();

View File

@@ -13,6 +13,7 @@ import {
import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import type Media from '@server/entity/Media'; import type Media from '@server/entity/Media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import axios from 'axios';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -93,13 +94,9 @@ const RequestButton = ({
request: MediaRequest, request: MediaRequest,
type: 'approve' | 'decline' type: 'approve' | 'decline'
) => { ) => {
const res = await fetch(`/api/v1/request/${request.id}/${type}`, { const response = await axios.post(`/api/v1/request/${request.id}/${type}`);
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (data) { if (response) {
onUpdate(); onUpdate();
} }
}; };
@@ -114,11 +111,7 @@ const RequestButton = ({
await Promise.all( await Promise.all(
requests.map(async (request) => { requests.map(async (request) => {
const res = await fetch(`/api/v1/request/${request.id}/${type}`, { return axios.post(`/api/v1/request/${request.id}/${type}`);
method: 'POST',
});
if (!res.ok) throw new Error();
return res.json();
}) })
); );

View File

@@ -22,7 +22,7 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common'; import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import Image from 'next/image'; import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
@@ -74,10 +74,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
}); });
const deleteRequest = async () => { const deleteRequest = async () => {
const res = await fetch(`/api/v1/media/${requestData?.media.id}`, { await axios.delete(`/api/v1/media/${requestData?.media.id}`);
method: 'DELETE',
});
if (!res.ok) throw new Error();
mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded'); mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded');
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
}; };
@@ -116,7 +113,8 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
className="group flex items-center" className="group flex items-center"
> >
<span className="avatar-sm"> <span className="avatar-sm">
<Image <CachedImage
type="avatar"
src={requestData.requestedBy.avatar} src={requestData.requestedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
@@ -261,22 +259,15 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
}); });
const modifyRequest = async (type: 'approve' | 'decline') => { const modifyRequest = async (type: 'approve' | 'decline') => {
const res = await fetch(`/api/v1/request/${request.id}/${type}`, { const response = await axios.post(`/api/v1/request/${request.id}/${type}`);
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (data) { if (response) {
revalidate(); revalidate();
} }
}; };
const deleteRequest = async () => { const deleteRequest = async () => {
const res = await fetch(`/api/v1/request/${request.id}`, { await axios.delete(`/api/v1/request/${request.id}`);
method: 'DELETE',
});
if (!res.ok) throw new Error();
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
}; };
@@ -284,13 +275,9 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
setRetrying(true); setRetrying(true);
try { try {
const res = await fetch(`/api/v1/request/${request.id}/retry`, { const response = await axios.post(`/api/v1/request/${request.id}/retry`);
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (data) { if (response) {
revalidate(); revalidate();
} }
} catch (e) { } catch (e) {
@@ -346,6 +333,7 @@ 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' }}
@@ -390,7 +378,8 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
className="group flex items-center" className="group flex items-center"
> >
<span className="avatar-sm"> <span className="avatar-sm">
<Image <CachedImage
type="avatar"
src={requestData.requestedBy.avatar} src={requestData.requestedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
@@ -603,6 +592,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
className="w-20 flex-shrink-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-md shadow-sm transition duration-300 hover:scale-105 hover:shadow-md sm:w-28" 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

@@ -21,7 +21,7 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common'; import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import Image from 'next/image'; import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
@@ -43,6 +43,7 @@ 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',
}); });
@@ -63,10 +64,7 @@ const RequestItemError = ({
const { hasPermission } = useUser(); const { hasPermission } = useUser();
const deleteRequest = async () => { const deleteRequest = async () => {
const res = await fetch(`/api/v1/media/${requestData?.media.id}`, { await axios.delete(`/api/v1/media/${requestData?.media.id}`);
method: 'DELETE',
});
if (!res.ok) throw new Error();
revalidateList(); revalidateList();
}; };
@@ -190,7 +188,8 @@ const RequestItemError = ({
className="group flex items-center truncate" className="group flex items-center truncate"
> >
<span className="avatar-sm ml-1.5"> <span className="avatar-sm ml-1.5">
<Image <CachedImage
type="avatar"
src={requestData.requestedBy.avatar} src={requestData.requestedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
@@ -249,7 +248,8 @@ const RequestItemError = ({
className="group flex items-center truncate" className="group flex items-center truncate"
> >
<span className="avatar-sm ml-1.5"> <span className="avatar-sm ml-1.5">
<Image <CachedImage
type="avatar"
src={requestData.modifiedBy.avatar} src={requestData.modifiedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
@@ -322,36 +322,36 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const [isRetrying, setRetrying] = useState(false); const [isRetrying, setRetrying] = useState(false);
const modifyRequest = async (type: 'approve' | 'decline') => { const modifyRequest = async (type: 'approve' | 'decline') => {
const res = await fetch(`/api/v1/request/${request.id}/${type}`, { const response = await axios.post(`/api/v1/request/${request.id}/${type}`);
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (data) { if (response) {
revalidate(); revalidate();
} }
}; };
const deleteRequest = async () => { const deleteRequest = async () => {
const res = await fetch(`/api/v1/request/${request.id}`, { await axios.delete(`/api/v1/request/${request.id}`);
method: 'DELETE',
});
if (!res.ok) throw new Error();
revalidateList(); revalidateList();
}; };
const deleteMediaFile = async () => {
if (request.media) {
try {
await axios.delete(`/api/v1/media/${request.media.id}/file`);
await axios.delete(`/api/v1/media/${request.media.id}`);
} catch {
/* empty */
}
revalidateList();
}
};
const retryRequest = async () => { const retryRequest = async () => {
setRetrying(true); setRetrying(true);
try { try {
const res = await fetch(`/api/v1/request/${request.id}/retry`, { const result = await axios.post(`/api/v1/request/${request.id}/retry`);
method: 'POST',
});
if (!res.ok) throw new Error();
const result = await res.json();
revalidate(result.data); revalidate(result.data);
} catch (e) { } catch (e) {
addToast(intl.formatMessage(messages.failedretry), { addToast(intl.formatMessage(messages.failedretry), {
@@ -406,6 +406,7 @@ 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' }}
@@ -431,6 +432,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105" 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}`
@@ -557,7 +559,8 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
className="group flex items-center truncate" className="group flex items-center truncate"
> >
<span className="avatar-sm ml-1.5"> <span className="avatar-sm ml-1.5">
<Image <CachedImage
type="avatar"
src={requestData.requestedBy.avatar} src={requestData.requestedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
@@ -616,8 +619,9 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
className="group flex items-center truncate" className="group flex items-center truncate"
> >
<span className="avatar-sm ml-1.5"> <span className="avatar-sm ml-1.5">
<Image <CachedImage
src={requestData.requestedBy.avatar} type="avatar"
src={requestData.modifiedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
width={20} width={20}
@@ -667,14 +671,28 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
)} )}
{requestData.status !== MediaRequestStatus.PENDING && {requestData.status !== MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && ( hasPermission(Permission.MANAGE_REQUESTS) && (
<ConfirmButton <>
onClick={() => deleteRequest()} <ConfirmButton
confirmText={intl.formatMessage(globalMessages.areyousure)} onClick={() => deleteRequest()}
className="w-full" confirmText={intl.formatMessage(globalMessages.areyousure)}
> className="w-full"
<TrashIcon /> >
<span>{intl.formatMessage(messages.deleterequest)}</span> <TrashIcon />
</ConfirmButton> <span>{intl.formatMessage(messages.deleterequest)}</span>
</ConfirmButton>
<ConfirmButton
onClick={() => deleteMediaFile()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>
{intl.formatMessage(messages.removearr, {
arr: request.type === 'movie' ? 'Radarr' : 'Sonarr',
})}
</span>
</ConfirmButton>
</>
)} )}
{requestData.status === MediaRequestStatus.PENDING && {requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && ( hasPermission(Permission.MANAGE_REQUESTS) && (

View File

@@ -1,4 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import CachedImage from '@app/components/Common/CachedImage';
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner'; import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import type { User } from '@app/hooks/useUser'; import type { User } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
@@ -14,7 +15,6 @@ import type {
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'; import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import { hasPermission } from '@server/lib/permissions'; import { hasPermission } from '@server/lib/permissions';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import Image from 'next/image';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import Select from 'react-select'; import Select from 'react-select';
@@ -561,7 +561,8 @@ const AdvancedRequester = ({
<span className="inline-block w-full rounded-md shadow-sm"> <span className="inline-block w-full rounded-md shadow-sm">
<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">
<Image <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"
@@ -613,7 +614,8 @@ const AdvancedRequester = ({
selected ? 'font-semibold' : 'font-normal' selected ? 'font-semibold' : 'font-normal'
} flex items-center`} } flex items-center`}
> >
<Image <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

@@ -13,6 +13,7 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces'; import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import type { Collection } from '@server/models/Collection'; import type { Collection } from '@server/models/Collection';
import axios from 'axios';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
@@ -198,19 +199,12 @@ const CollectionRequestModal = ({
( (
data?.parts.filter((part) => selectedParts.includes(part.id)) ?? [] data?.parts.filter((part) => selectedParts.includes(part.id)) ?? []
).map(async (part) => { ).map(async (part) => {
const res = await fetch('/api/v1/request', { await axios.post<MediaRequest>('/api/v1/request', {
method: 'POST', mediaId: part.id,
headers: { mediaType: 'movie',
'Content-Type': 'application/json', is4k,
}, ...overrideParams,
body: JSON.stringify({
mediaId: part.id,
mediaType: 'movie',
is4k,
...overrideParams,
}),
}); });
if (!res.ok) throw new Error();
}) })
); );
@@ -437,6 +431,7 @@ 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

@@ -12,6 +12,7 @@ import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces'; import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import axios from 'axios';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
@@ -89,23 +90,15 @@ const MovieRequestModal = ({
tags: requestOverrides.tags, tags: requestOverrides.tags,
}; };
} }
const res = await fetch('/api/v1/request', { const response = await axios.post<MediaRequest>('/api/v1/request', {
method: 'POST', mediaId: data?.id,
headers: { mediaType: 'movie',
'Content-Type': 'application/json', is4k,
}, ...overrideParams,
body: JSON.stringify({
mediaId: data?.id,
mediaType: 'movie',
is4k,
...overrideParams,
}),
}); });
if (!res.ok) throw new Error();
const mediaRequest: MediaRequest = await res.json();
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
if (mediaRequest) { if (response.data) {
if (onComplete) { if (onComplete) {
onComplete( onComplete(
hasPermission( hasPermission(
@@ -144,14 +137,12 @@ const MovieRequestModal = ({
setIsUpdating(true); setIsUpdating(true);
try { try {
const res = await fetch(`/api/v1/request/${editRequest?.id}`, { const response = await axios.delete<MediaRequest>(
method: 'DELETE', `/api/v1/request/${editRequest?.id}`
}); );
if (!res.ok) throw new Error();
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
if (res.status === 204) { if (response.status === 204) {
if (onComplete) { if (onComplete) {
onComplete(MediaStatus.UNKNOWN); onComplete(MediaStatus.UNKNOWN);
} }
@@ -174,27 +165,17 @@ const MovieRequestModal = ({
setIsUpdating(true); setIsUpdating(true);
try { try {
const res = await fetch(`/api/v1/request/${editRequest?.id}`, { await axios.put(`/api/v1/request/${editRequest?.id}`, {
method: 'PUT', mediaType: 'movie',
headers: { serverId: requestOverrides?.server,
'Content-Type': 'application/json', profileId: requestOverrides?.profile,
}, rootFolder: requestOverrides?.folder,
body: JSON.stringify({ userId: requestOverrides?.user?.id,
mediaType: 'movie', tags: requestOverrides?.tags,
serverId: requestOverrides?.server,
profileId: requestOverrides?.profile,
rootFolder: requestOverrides?.folder,
userId: requestOverrides?.user?.id,
tags: requestOverrides?.tags,
}),
}); });
if (!res.ok) throw new Error();
if (alsoApproveRequest) { if (alsoApproveRequest) {
const res = await fetch(`/api/v1/request/${editRequest?.id}/approve`, { await axios.post(`/api/v1/request/${editRequest?.id}/approve`);
method: 'POST',
});
if (!res.ok) throw new Error();
} }
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');

View File

@@ -17,6 +17,7 @@ import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces'; import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
@@ -111,35 +112,22 @@ const TvRequestModal = ({
try { try {
if (selectedSeasons.length > 0) { if (selectedSeasons.length > 0) {
const res = await fetch(`/api/v1/request/${editRequest.id}`, { await axios.put(`/api/v1/request/${editRequest.id}`, {
method: 'PUT', mediaType: 'tv',
headers: { serverId: requestOverrides?.server,
'Content-Type': 'application/json', profileId: requestOverrides?.profile,
}, rootFolder: requestOverrides?.folder,
body: JSON.stringify({ languageProfileId: requestOverrides?.language,
mediaType: 'tv', userId: requestOverrides?.user?.id,
serverId: requestOverrides?.server, tags: requestOverrides?.tags,
profileId: requestOverrides?.profile, seasons: selectedSeasons,
rootFolder: requestOverrides?.folder,
languageProfileId: requestOverrides?.language,
userId: requestOverrides?.user?.id,
tags: requestOverrides?.tags,
seasons: selectedSeasons,
}),
}); });
if (!res.ok) throw new Error();
if (alsoApproveRequest) { if (alsoApproveRequest) {
const res = await fetch(`/api/v1/request/${editRequest.id}/approve`, { await axios.post(`/api/v1/request/${editRequest.id}/approve`);
method: 'POST',
});
if (!res.ok) throw new Error();
} }
} else { } else {
const res = await fetch(`/api/v1/request/${editRequest.id}`, { await axios.delete(`/api/v1/request/${editRequest.id}`);
method: 'DELETE',
});
if (!res.ok) throw new Error();
} }
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
@@ -204,32 +192,23 @@ const TvRequestModal = ({
tags: requestOverrides.tags, tags: requestOverrides.tags,
}; };
} }
const res = await fetch('/api/v1/request', { const response = await axios.post<MediaRequest>('/api/v1/request', {
method: 'POST', mediaId: data?.id,
headers: { tvdbId: tvdbId ?? data?.externalIds.tvdbId,
'Content-Type': 'application/json', mediaType: 'tv',
}, is4k,
body: JSON.stringify({ seasons: settings.currentSettings.partialRequestsEnabled
mediaId: data?.id, ? selectedSeasons
tvdbId: tvdbId ?? data?.externalIds.tvdbId, : getAllSeasons().filter(
mediaType: 'tv', (season) => !getAllRequestedSeasons().includes(season)
is4k, ),
seasons: settings.currentSettings.partialRequestsEnabled ...overrideParams,
? selectedSeasons
: getAllSeasons().filter(
(season) => !getAllRequestedSeasons().includes(season)
),
...overrideParams,
}),
}); });
if (!res.ok) throw new Error();
const mediaRequest: MediaRequest = await res.json();
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
if (mediaRequest) { if (response.data) {
if (onComplete) { if (onComplete) {
onComplete(mediaRequest.media.status); onComplete(response.data.media.status);
} }
addToast( addToast(
<span> <span>

Some files were not shown because too many files have changed in this diff Show More