Compare commits
26 Commits
preview-po
...
preview-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9803bc40df | ||
|
|
64f4610b9f | ||
|
|
2d3b777daf | ||
|
|
cf59102ef9 | ||
|
|
ca838a00fa | ||
|
|
f2ed101e52 | ||
|
|
4b4eeb6ec7 | ||
|
|
d331798b28 | ||
|
|
f2b63156d1 | ||
|
|
326001c3ec | ||
|
|
0bbcfcbd5e | ||
|
|
32e0b129fe | ||
|
|
a2b3408c9a | ||
|
|
cbb1a74526 | ||
|
|
26c37ec067 | ||
|
|
4e48fdf2cb | ||
|
|
a351264b87 | ||
|
|
9de304d17a | ||
|
|
4945b54298 | ||
|
|
a0f80fe764 | ||
|
|
92ba26207d | ||
|
|
96e1d40304 | ||
|
|
a5d22ba5b8 | ||
|
|
f390da4866 | ||
|
|
edfd80444c | ||
|
|
2b05ffface |
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
2
.gitattributes
vendored
@@ -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
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -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:
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
2
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -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*
|
||||||
|
|||||||
@@ -8,4 +8,3 @@ pnpm-lock.yaml
|
|||||||
# assets
|
# assets
|
||||||
src/assets/
|
src/assets/
|
||||||
public/
|
public/
|
||||||
docs/
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
version: '3'
|
|
||||||
services:
|
services:
|
||||||
jellyseerr:
|
jellyseerr:
|
||||||
build:
|
build:
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
:::
|
:::
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
2
next-env.d.ts
vendored
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
8206
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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}`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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' }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ class PlexAPI {
|
|||||||
settings.plex.libraries = [];
|
settings.plex.libraries = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.save();
|
await settings.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getLibraryContents(
|
public async getLibraryContents(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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, ' ')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
86
server/routes/avatarproxy.ts
Normal file
86
server/routes/avatarproxy.ts
Normal 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;
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
111
server/utils/customProxyAgent.ts
Normal file
111
server/utils/customProxyAgent.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import type { ProxySettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import type { Dispatcher } from 'undici';
|
||||||
|
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||||
|
|
||||||
|
export default async function createCustomProxyAgent(
|
||||||
|
proxySettings: ProxySettings
|
||||||
|
) {
|
||||||
|
const defaultAgent = new Agent();
|
||||||
|
|
||||||
|
const skipUrl = (url: string) => {
|
||||||
|
const hostname = new URL(url).hostname;
|
||||||
|
|
||||||
|
if (proxySettings.bypassLocalAddresses && isLocalAddress(hostname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const address of proxySettings.bypassFilter.split(',')) {
|
||||||
|
const trimmedAddress = address.trim();
|
||||||
|
if (!trimmedAddress) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedAddress.startsWith('*')) {
|
||||||
|
const domain = trimmedAddress.slice(1);
|
||||||
|
if (hostname.endsWith(domain)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (hostname === trimmedAddress) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const noProxyInterceptor = (
|
||||||
|
dispatch: Dispatcher['dispatch']
|
||||||
|
): Dispatcher['dispatch'] => {
|
||||||
|
return (opts, handler) => {
|
||||||
|
const url = opts.origin?.toString();
|
||||||
|
return url && skipUrl(url)
|
||||||
|
? defaultAgent.dispatch(opts, handler)
|
||||||
|
: dispatch(opts, handler);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const token =
|
||||||
|
proxySettings.user && proxySettings.password
|
||||||
|
? `Basic ${Buffer.from(
|
||||||
|
`${proxySettings.user}:${proxySettings.password}`
|
||||||
|
).toString('base64')}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proxyAgent = new ProxyAgent({
|
||||||
|
uri:
|
||||||
|
(proxySettings.useSsl ? 'https://' : 'http://') +
|
||||||
|
proxySettings.hostname +
|
||||||
|
':' +
|
||||||
|
proxySettings.port,
|
||||||
|
token,
|
||||||
|
interceptors: {
|
||||||
|
Client: [noProxyInterceptor],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setGlobalDispatcher(proxyAgent);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to connect to the proxy: ' + e.message, {
|
||||||
|
label: 'Proxy',
|
||||||
|
});
|
||||||
|
setGlobalDispatcher(defaultAgent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://www.google.com', { method: 'HEAD' });
|
||||||
|
if (res.ok) {
|
||||||
|
logger.debug('HTTP(S) proxy connected successfully', { label: 'Proxy' });
|
||||||
|
} else {
|
||||||
|
logger.error('Proxy responded, but with a non-OK status: ' + res.status, {
|
||||||
|
label: 'Proxy',
|
||||||
|
});
|
||||||
|
setGlobalDispatcher(defaultAgent);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
'Failed to connect to the proxy: ' + e.message + ': ' + e.cause,
|
||||||
|
{ label: 'Proxy' }
|
||||||
|
);
|
||||||
|
setGlobalDispatcher(defaultAgent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalAddress(hostname: string) {
|
||||||
|
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateIpRanges = [
|
||||||
|
/^10\./, // 10.x.x.x
|
||||||
|
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.x.x - 172.31.x.x
|
||||||
|
/^192\.168\./, // 192.168.x.x
|
||||||
|
];
|
||||||
|
if (privateIpRanges.some((regex) => regex.test(hostname))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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' }}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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' }}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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' }}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|||||||
@@ -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) && (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user