Compare commits
1 Commits
preview-po
...
preview-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9803bc40df |
@@ -448,69 +448,6 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"security"
|
"security"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "j0srisk",
|
|
||||||
"name": "Joseph Risk",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
|
|
||||||
"profile": "http://josephrisk.com",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "Loetwiek",
|
|
||||||
"name": "Loetwiek",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
|
|
||||||
"profile": "https://github.com/Loetwiek",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "Fuochi",
|
|
||||||
"name": "Fuochi",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
|
|
||||||
"profile": "https://github.com/Fuochi",
|
|
||||||
"contributions": [
|
|
||||||
"doc"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "demrich",
|
|
||||||
"name": "David Emrich",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4",
|
|
||||||
"profile": "https://github.com/demrich",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "maxnatamo",
|
|
||||||
"name": "Max T. Kristiansen",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4",
|
|
||||||
"profile": "https://maxtrier.dk",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "DamsDev1",
|
|
||||||
"name": "Damien Fajole",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4",
|
|
||||||
"profile": "https://damsdev.me",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "AhmedNSidd",
|
|
||||||
"name": "Ahmed Siddiqui",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4",
|
|
||||||
"profile": "https://github.com/AhmedNSidd",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -55,14 +55,6 @@ body:
|
|||||||
- tablet
|
- tablet
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
|
||||||
id: database
|
|
||||||
attributes:
|
|
||||||
options:
|
|
||||||
- SQLite (default)
|
|
||||||
- PostgreSQL
|
|
||||||
label: Database
|
|
||||||
description: Which database backend are you using?
|
|
||||||
- type: input
|
- type: input
|
||||||
id: device
|
id: device
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
|||||||
4. Run the development environment:
|
4. Run the development environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -291,12 +291,6 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=demrich" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=maxnatamo" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=DamsDev1" title="Code">💻</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -75,7 +75,6 @@
|
|||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"webhookUrl": "",
|
"webhookUrl": "",
|
||||||
"webhookRoleId": "",
|
|
||||||
"enableMentions": true
|
"enableMentions": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
version: '3.8'
|
|
||||||
services:
|
|
||||||
jellyseerr:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.local
|
|
||||||
ports:
|
|
||||||
- '5055:5055'
|
|
||||||
environment:
|
|
||||||
DB_TYPE: 'postgres' # Which DB engine to use. The default is "sqlite". To use postgres, this needs to be set to "postgres"
|
|
||||||
DB_HOST: 'postgres' # The host (url) of the database
|
|
||||||
DB_PORT: '5432' # The port to connect to
|
|
||||||
DB_USER: 'jellyseerr' # Username used to connect to the database
|
|
||||||
DB_PASS: 'jellyseerr' # Password of the user used to connect to the database
|
|
||||||
DB_NAME: 'jellyseerr' # The name of the database to connect to
|
|
||||||
DB_LOG_QUERIES: 'false' # Whether to log the DB queries for debugging
|
|
||||||
DB_USE_SSL: 'false' # Whether to enable ssl for database connection
|
|
||||||
volumes:
|
|
||||||
- .:/app:rw,cached
|
|
||||||
- /app/node_modules
|
|
||||||
- /app/.next
|
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
links:
|
|
||||||
- postgres
|
|
||||||
postgres:
|
|
||||||
image: postgres
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: jellyseerr
|
|
||||||
POSTGRES_PASSWORD: jellyseerr
|
|
||||||
POSTGRES_DB: jellyseerr
|
|
||||||
ports:
|
|
||||||
- '5432:5432'
|
|
||||||
volumes:
|
|
||||||
- postgres:/var/lib/postgresql/data
|
|
||||||
volumes:
|
|
||||||
postgres:
|
|
||||||
@@ -17,7 +17,6 @@ Welcome to the Jellyseerr Documentation.
|
|||||||
- **Mobile-friendly design**, for when you need to approve requests on the go.
|
- **Mobile-friendly design**, for when you need to approve requests on the go.
|
||||||
- Granular permission system.
|
- Granular permission system.
|
||||||
- Localization into other languages.
|
- Localization into other languages.
|
||||||
- Support for PostgreSQL and SQLite databases.
|
|
||||||
- More features to come!
|
- More features to come!
|
||||||
|
|
||||||
## Motivation
|
## Motivation
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
---
|
|
||||||
title: Configuring the Database (Advanced)
|
|
||||||
description: Configure the database for Jellyseerr
|
|
||||||
sidebar_position: 2
|
|
||||||
---
|
|
||||||
# Configuring the Database
|
|
||||||
|
|
||||||
Jellyseerr supports SQLite and PostgreSQL. The database connection can be configured using the following environment variables:
|
|
||||||
|
|
||||||
## SQLite Options
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
DB_TYPE="sqlite" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite".
|
|
||||||
CONFIG_DIRECTORY="config" # (optional) The path to the config directory where the db file is stored. The default is "config".
|
|
||||||
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
|
|
||||||
```
|
|
||||||
|
|
||||||
## PostgreSQL Options
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
DB_TYPE="postgres" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite". To use postgres, this needs to be set to "postgres"
|
|
||||||
DB_HOST="localhost" # (optional) The host (url) of the database. The default is "localhost".
|
|
||||||
DB_PORT="5432" # (optional) The port to connect to. The default is "5432".
|
|
||||||
DB_USER= # (required) Username used to connect to the database
|
|
||||||
DB_PASS= # (required) Password of the user used to connect to the database
|
|
||||||
DB_NAME="jellyseerr" # (optional) The name of the database to connect to. The default is "jellyseerr".
|
|
||||||
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
|
|
||||||
```
|
|
||||||
|
|
||||||
### SSL configuration
|
|
||||||
The following options can be used to further configure ssl. Certificates can be provided as a string or a file path, with the string version taking precedence.
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
DB_USE_SSL="false" # (optional) Whether to enable ssl for database connection. This must be "true" to use the other ssl options. The default is "false".
|
|
||||||
DB_SSL_REJECT_UNAUTHORIZED="true" # (optional) Whether to reject ssl connections with unverifiable certificates i.e. self-signed certificates without providing the below settings. The default is "true".
|
|
||||||
DB_SSL_CA= # (optional) The CA certificate to verify the connection, provided as a string. The default is "".
|
|
||||||
DB_SSL_CA_FILE= # (optional) The path to a CA certificate to verify the connection. The default is "".
|
|
||||||
DB_SSL_KEY= # (optional) The private key for the connection in PEM format, provided as a string. The default is "".
|
|
||||||
DB_SSL_KEY_FILE= # (optinal) Path to the private key for the connection in PEM format. The default is "".
|
|
||||||
DB_SSL_CERT= # (optional) Certificate chain in pem format for the private key, provided as a string. The default is "".
|
|
||||||
DB_SSL_CERT_FILE= # (optional) Path to certificate chain in pem format for the private key. The default is "".
|
|
||||||
```
|
|
||||||
|
|
||||||
### Migrating from SQLite to PostgreSQL
|
|
||||||
|
|
||||||
1. Set up your PostgreSQL database and configure Jellyseerr to use it
|
|
||||||
2. Run Jellyseerr to create the tables in the PostgreSQL database
|
|
||||||
3. Stop Jellyseerr
|
|
||||||
4. Run the following command to export the data from the SQLite database and import it into the PostgreSQL database:
|
|
||||||
- Edit the postgres connection string to match your setup
|
|
||||||
- WARNING: The most recent release of pgloader has an issue quoting the table columns. Use the version in the docker container to avoid this issue.
|
|
||||||
- "I don't have or don't want to use docker" - You can build the working pgloader version [in this PR](https://github.com/dimitri/pgloader/pull/1531) from source and use the same options as below.
|
|
||||||
```bash
|
|
||||||
docker run --rm -v config/db.sqlite3:/db.sqlite3:ro -v pgloader/pgloader.load:/pgloader.load ghcr.io/ralgar/pgloader:pr-1531 pgloader --with "quote identifiers" --with "data only" /db.sqlite3 postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}}
|
|
||||||
```
|
|
||||||
5. Start Jellyseerr
|
|
||||||
@@ -95,8 +95,6 @@ location ^~ /jellyseerr {
|
|||||||
sub_filter '/api/v1' '/$app/api/v1';
|
sub_filter '/api/v1' '/$app/api/v1';
|
||||||
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
||||||
sub_filter '/images/' '/$app/images/';
|
sub_filter '/images/' '/$app/images/';
|
||||||
sub_filter '/imageproxy/' '/$app/imageproxy/';
|
|
||||||
sub_filter '/avatarproxy/' '/$app/avatarproxy/';
|
|
||||||
sub_filter '/android-' '/$app/android-';
|
sub_filter '/android-' '/$app/android-';
|
||||||
sub_filter '/apple-' '/$app/apple-';
|
sub_filter '/apple-' '/$app/apple-';
|
||||||
sub_filter '/favicon' '/$app/favicon';
|
sub_filter '/favicon' '/$app/favicon';
|
||||||
|
|||||||
@@ -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,31 +14,43 @@ import Tabs from '@theme/Tabs';
|
|||||||
import TabItem from '@theme/TabItem';
|
import TabItem from '@theme/TabItem';
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [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)
|
||||||
|
|
||||||
## Unix (Linux, macOS)
|
## Unix (Linux, macOS)
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
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
|
||||||
```
|
```
|
||||||
@@ -46,6 +60,7 @@ You can now access Jellyseerr by visiting `http://localhost:5055` in your web br
|
|||||||
:::
|
:::
|
||||||
|
|
||||||
#### 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:
|
||||||
@@ -56,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
|
||||||
@@ -88,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:
|
||||||
@@ -107,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">
|
||||||
@@ -135,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:
|
||||||
@@ -194,27 +221,38 @@ pm2 status jellyseerr
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
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
|
||||||
```
|
```
|
||||||
@@ -228,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:
|
||||||
@@ -249,6 +288,7 @@ node dist/index.js
|
|||||||
- 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">
|
||||||
@@ -311,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.
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ Users can optionally opt-in to being mentioned in Discord notifications by confi
|
|||||||
|
|
||||||
You can find the webhook URL in the Discord application, at **Server Settings → Integrations → Webhooks**.
|
You can find the webhook URL in the Discord application, at **Server Settings → Integrations → Webhooks**.
|
||||||
|
|
||||||
### Notification Role ID (optional)
|
|
||||||
|
|
||||||
If a role ID is specified, it will be included in the webhook message. See [Discord role ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID).
|
|
||||||
|
|
||||||
### Bot Username (optional)
|
### Bot Username (optional)
|
||||||
|
|
||||||
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!
|
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!
|
||||||
|
|||||||
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: 'artworks.thetvdb.com' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
|
|||||||
@@ -1273,8 +1273,6 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
webhookUrl:
|
webhookUrl:
|
||||||
type: string
|
type: string
|
||||||
webhookRoleId:
|
|
||||||
type: string
|
|
||||||
enableMentions:
|
enableMentions:
|
||||||
type: boolean
|
type: boolean
|
||||||
SlackSettings:
|
SlackSettings:
|
||||||
@@ -4144,21 +4142,6 @@ paths:
|
|||||||
'412':
|
'412':
|
||||||
description: Item has already been blacklisted
|
description: Item has already been blacklisted
|
||||||
/blacklist/{tmdbId}:
|
/blacklist/{tmdbId}:
|
||||||
get:
|
|
||||||
summary: Get media from blacklist
|
|
||||||
tags:
|
|
||||||
- blacklist
|
|
||||||
parameters:
|
|
||||||
- in: path
|
|
||||||
name: tmdbId
|
|
||||||
description: tmdbId ID
|
|
||||||
required: true
|
|
||||||
example: '1'
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Blacklist details in JSON
|
|
||||||
delete:
|
delete:
|
||||||
summary: Remove media from blacklist
|
summary: Remove media from blacklist
|
||||||
tags:
|
tags:
|
||||||
@@ -5486,7 +5469,7 @@ paths:
|
|||||||
- type: array
|
- type: array
|
||||||
items:
|
items:
|
||||||
type: number
|
type: number
|
||||||
minimum: 0
|
minimum: 1
|
||||||
- type: string
|
- type: string
|
||||||
enum: [all]
|
enum: [all]
|
||||||
is4k:
|
is4k:
|
||||||
@@ -5592,7 +5575,7 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: number
|
type: number
|
||||||
minimum: 0
|
minimum: 1
|
||||||
is4k:
|
is4k:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
|
|||||||
@@ -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,6 +62,7 @@
|
|||||||
"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",
|
"mime": "3",
|
||||||
@@ -69,7 +72,6 @@
|
|||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.9.1",
|
"nodemailer": "6.9.1",
|
||||||
"openpgp": "5.7.0",
|
"openpgp": "5.7.0",
|
||||||
"pg": "8.11.0",
|
|
||||||
"plex-api": "5.3.2",
|
"plex-api": "5.3.2",
|
||||||
"pug": "3.0.2",
|
"pug": "3.0.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
8244
pnpm-lock.yaml
generated
8244
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,101 +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,
|
||||||
}
|
});
|
||||||
|
|
||||||
const url = new URL(baseUrl);
|
|
||||||
|
|
||||||
this.defaultHeaders = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
...((url.username || url.password) && {
|
|
||||||
Authorization: `Basic ${Buffer.from(
|
|
||||||
`${url.username}:${url.password}`
|
|
||||||
).toString('base64')}`,
|
|
||||||
}),
|
|
||||||
...options.headers,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (url.username || url.password) {
|
|
||||||
url.username = '';
|
|
||||||
url.password = '';
|
|
||||||
baseUrl = url.toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
this.params = params;
|
|
||||||
this.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 && ttl !== 0) {
|
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);
|
||||||
@@ -113,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 && ttl !== 0) {
|
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 && ttl !== 0) {
|
|
||||||
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) {
|
||||||
@@ -234,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(
|
||||||
@@ -318,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 }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -138,38 +138,39 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
try {
|
try {
|
||||||
return await authenticate(true);
|
return await authenticate(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug('Failed to authenticate with headers', {
|
logger.debug(`Failed to authenticate with headers: ${e.message}`, {
|
||||||
label: 'Jellyfin API',
|
label: 'Jellyfin API',
|
||||||
error: e.cause.message ?? e.cause.statusText,
|
|
||||||
ip: ClientIP,
|
ip: ClientIP,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!e.cause.status) {
|
|
||||||
throw new ApiError(404, ApiErrorCode.InvalidUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.cause.status === 401) {
|
|
||||||
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await authenticate(false);
|
return await authenticate(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.cause.status === 401) {
|
const status = e.cause?.status;
|
||||||
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
|
|
||||||
|
const networkErrorCodes = new Set([
|
||||||
|
'ECONNREFUSED',
|
||||||
|
'EHOSTUNREACH',
|
||||||
|
'ENOTFOUND',
|
||||||
|
'ETIMEDOUT',
|
||||||
|
'ECONNRESET',
|
||||||
|
'EADDRINUSE',
|
||||||
|
'ENETDOWN',
|
||||||
|
'ENETUNREACH',
|
||||||
|
'EPIPE',
|
||||||
|
'ECONNABORTED',
|
||||||
|
'EPROTO',
|
||||||
|
'EHOSTDOWN',
|
||||||
|
'EAI_AGAIN',
|
||||||
|
'ERR_INVALID_URL',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (networkErrorCodes.has(e.code) || status === 404) {
|
||||||
|
throw new ApiError(status, ApiErrorCode.InvalidUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error(
|
throw new ApiError(status, ApiErrorCode.InvalidCredentials);
|
||||||
'Something went wrong while authenticating with the Jellyfin server',
|
|
||||||
{
|
|
||||||
label: 'Jellyfin API',
|
|
||||||
error: e.cause.message ?? e.cause.statusText,
|
|
||||||
ip: ClientIP,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
throw new ApiError(e.cause.status, ApiErrorCode.Unknown);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,8 +198,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return serverResponse.ServerName;
|
return serverResponse.ServerName;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the server name from the Jellyfin server',
|
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
|
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
|
||||||
@@ -212,8 +213,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return { users: userReponse };
|
return { users: userReponse };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the account from the Jellyfin server',
|
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -228,8 +229,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return userReponse;
|
return userReponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the account from the Jellyfin server',
|
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -252,11 +253,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return this.mapLibraries(mediaFolderResponse.Items);
|
return this.mapLibraries(mediaFolderResponse.Items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting libraries from the Jellyfin server',
|
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
|
||||||
{
|
{ label: 'Jellyfin API' }
|
||||||
label: 'Jellyfin API',
|
|
||||||
error: e.cause.message ?? e.cause.statusText,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
@@ -293,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(
|
||||||
@@ -310,8 +299,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting library content from the Jellyfin server',
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -321,18 +310,14 @@ 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;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting library content from the Jellyfin server',
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -356,8 +341,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting library content from the Jellyfin server',
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
@@ -370,8 +355,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return seasonResponse.Items;
|
return seasonResponse.Items;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the list of seasons from the Jellyfin server',
|
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -384,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(
|
||||||
@@ -395,8 +377,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the list of episodes from the Jellyfin server',
|
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -412,8 +394,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
).AccessToken;
|
).AccessToken;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while creating an API key from the Jellyfin server',
|
`Something went wrong while creating an API key from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
|
|||||||
@@ -1,10 +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 { randomUUID } from 'node:crypto';
|
|
||||||
import xml2js from 'xml2js';
|
import xml2js from 'xml2js';
|
||||||
|
import ExternalAPI from './externalapi';
|
||||||
|
|
||||||
interface PlexAccountResponse {
|
interface PlexAccountResponse {
|
||||||
user: PlexUser;
|
user: PlexUser;
|
||||||
@@ -128,11 +127,6 @@ export interface PlexWatchlistItem {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlexWatchlistCache {
|
|
||||||
etag: string;
|
|
||||||
response: WatchlistResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
class PlexTvAPI extends ExternalAPI {
|
class PlexTvAPI extends ExternalAPI {
|
||||||
private authToken: string;
|
private authToken: string;
|
||||||
|
|
||||||
@@ -143,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,
|
||||||
}
|
}
|
||||||
@@ -153,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,
|
||||||
@@ -205,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}`,
|
||||||
@@ -249,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;
|
||||||
}
|
}
|
||||||
@@ -267,50 +270,25 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
items: PlexWatchlistItem[];
|
items: PlexWatchlistItem[];
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const watchlistCache = cacheManager.getCache('plexwatchlist');
|
const response = await this.axios.get<WatchlistResponse>(
|
||||||
let cachedWatchlist = watchlistCache.data.get<PlexWatchlistCache>(
|
'/library/sections/watchlist/all',
|
||||||
this.authToken
|
|
||||||
);
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
'X-Plex-Container-Start': offset.toString(),
|
|
||||||
'X-Plex-Container-Size': size.toString(),
|
|
||||||
});
|
|
||||||
const response = await this.fetch(
|
|
||||||
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
|
|
||||||
{
|
{
|
||||||
headers: {
|
params: {
|
||||||
...this.defaultHeaders,
|
'X-Plex-Container-Start': offset,
|
||||||
...(cachedWatchlist?.etag
|
'X-Plex-Container-Size': size,
|
||||||
? { 'If-None-Match': cachedWatchlist.etag }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
|
baseURL: 'https://metadata.provider.plex.tv',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const data = (await response.json()) as WatchlistResponse;
|
|
||||||
|
|
||||||
// If we don't recieve HTTP 304, the watchlist has been updated and we need to update the cache.
|
|
||||||
if (response.status >= 200 && response.status <= 299) {
|
|
||||||
cachedWatchlist = {
|
|
||||||
etag: response.headers.get('etag') ?? '',
|
|
||||||
response: data,
|
|
||||||
};
|
|
||||||
|
|
||||||
watchlistCache.data.set<PlexWatchlistCache>(
|
|
||||||
this.authToken,
|
|
||||||
cachedWatchlist
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const watchlistDetails = await Promise.all(
|
const watchlistDetails = await Promise.all(
|
||||||
(cachedWatchlist?.response.MediaContainer.Metadata ?? []).map(
|
(response.data.MediaContainer.Metadata ?? []).map(
|
||||||
async (watchlistItem) => {
|
async (watchlistItem) => {
|
||||||
const detailedResponse = await this.getRolling<MetadataResponse>(
|
const detailedResponse = await this.getRolling<MetadataResponse>(
|
||||||
`/library/metadata/${watchlistItem.ratingKey}`,
|
`/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];
|
||||||
@@ -342,7 +320,7 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
return {
|
return {
|
||||||
offset,
|
offset,
|
||||||
size,
|
size,
|
||||||
totalSize: cachedWatchlist?.response.MediaContainer.totalSize ?? 0,
|
totalSize: response.data.MediaContainer.totalSize,
|
||||||
items: filteredList,
|
items: filteredList,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -358,29 +336,6 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async pingToken() {
|
|
||||||
try {
|
|
||||||
const data: { pong: unknown } = await this.get(
|
|
||||||
'/api/v2/ping',
|
|
||||||
{},
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'X-Plex-Client-Identifier': randomUUID(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!data?.pong) {
|
|
||||||
throw new Error('No pong response');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Failed to ping token', {
|
|
||||||
label: 'Plex Refresh Token',
|
|
||||||
errorMessage: e.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PlexTvAPI;
|
export default PlexTvAPI;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,15 +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>>(
|
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
|
||||||
`/queue`,
|
`/queue`,
|
||||||
{
|
{
|
||||||
includeEpisode: 'true',
|
params: {
|
||||||
},
|
includeEpisode: true,
|
||||||
0
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
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}`
|
||||||
@@ -175,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}`
|
||||||
@@ -187,11 +188,11 @@ 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}`);
|
||||||
}
|
}
|
||||||
@@ -206,15 +207,10 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
options: Record<string, unknown>
|
options: Record<string, unknown>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.post(
|
await this.axios.post(`/command`, {
|
||||||
`/command`,
|
name: commandName,
|
||||||
{
|
...options,
|
||||||
name: commandName,
|
});
|
||||||
...options,
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
0
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
|
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,43 +1,7 @@
|
|||||||
import fs from 'fs';
|
import 'reflect-metadata';
|
||||||
import type { TlsOptions } from 'tls';
|
|
||||||
import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm';
|
import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
const DB_SSL_PREFIX = 'DB_SSL_';
|
|
||||||
|
|
||||||
function boolFromEnv(envVar: string, defaultVal = false) {
|
|
||||||
if (process.env[envVar]) {
|
|
||||||
return process.env[envVar]?.toLowerCase() === 'true';
|
|
||||||
}
|
|
||||||
return defaultVal;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringOrReadFileFromEnv(envVar: string): Buffer | string | undefined {
|
|
||||||
if (process.env[envVar]) {
|
|
||||||
return process.env[envVar];
|
|
||||||
}
|
|
||||||
const filePath = process.env[`${envVar}_FILE`];
|
|
||||||
if (filePath) {
|
|
||||||
return fs.readFileSync(filePath);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSslConfig(): TlsOptions | undefined {
|
|
||||||
if (process.env.DB_USE_SSL?.toLowerCase() !== 'true') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
rejectUnauthorized: boolFromEnv(
|
|
||||||
`${DB_SSL_PREFIX}REJECT_UNAUTHORIZED`,
|
|
||||||
true
|
|
||||||
),
|
|
||||||
ca: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}CA`),
|
|
||||||
key: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}KEY`),
|
|
||||||
cert: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}CERT`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const devConfig: DataSourceOptions = {
|
const devConfig: DataSourceOptions = {
|
||||||
type: 'sqlite',
|
type: 'sqlite',
|
||||||
database: process.env.CONFIG_DIRECTORY
|
database: process.env.CONFIG_DIRECTORY
|
||||||
@@ -45,10 +9,10 @@ const devConfig: DataSourceOptions = {
|
|||||||
: 'config/db/db.sqlite3',
|
: 'config/db/db.sqlite3',
|
||||||
synchronize: true,
|
synchronize: true,
|
||||||
migrationsRun: false,
|
migrationsRun: false,
|
||||||
logging: boolFromEnv('DB_LOG_QUERIES'),
|
logging: false,
|
||||||
enableWAL: true,
|
enableWAL: true,
|
||||||
entities: ['server/entity/**/*.ts'],
|
entities: ['server/entity/**/*.ts'],
|
||||||
migrations: ['server/migration/sqlite/**/*.ts'],
|
migrations: ['server/migration/**/*.ts'],
|
||||||
subscribers: ['server/subscriber/**/*.ts'],
|
subscribers: ['server/subscriber/**/*.ts'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,56 +23,16 @@ const prodConfig: DataSourceOptions = {
|
|||||||
: 'config/db/db.sqlite3',
|
: 'config/db/db.sqlite3',
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
migrationsRun: false,
|
migrationsRun: false,
|
||||||
logging: boolFromEnv('DB_LOG_QUERIES'),
|
logging: false,
|
||||||
enableWAL: true,
|
enableWAL: true,
|
||||||
entities: ['dist/entity/**/*.js'],
|
entities: ['dist/entity/**/*.js'],
|
||||||
migrations: ['dist/migration/sqlite/**/*.js'],
|
migrations: ['dist/migration/**/*.js'],
|
||||||
subscribers: ['dist/subscriber/**/*.js'],
|
subscribers: ['dist/subscriber/**/*.js'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const postgresDevConfig: DataSourceOptions = {
|
const dataSource = new DataSource(
|
||||||
type: 'postgres',
|
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
|
||||||
host: process.env.DB_HOST,
|
);
|
||||||
port: parseInt(process.env.DB_PORT ?? '5432'),
|
|
||||||
username: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASS,
|
|
||||||
database: process.env.DB_NAME ?? 'jellyseerr',
|
|
||||||
ssl: buildSslConfig(),
|
|
||||||
synchronize: false,
|
|
||||||
migrationsRun: true,
|
|
||||||
logging: boolFromEnv('DB_LOG_QUERIES'),
|
|
||||||
entities: ['server/entity/**/*.ts'],
|
|
||||||
migrations: ['server/migration/postgres/**/*.ts'],
|
|
||||||
subscribers: ['server/subscriber/**/*.ts'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const postgresProdConfig: DataSourceOptions = {
|
|
||||||
type: 'postgres',
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
port: parseInt(process.env.DB_PORT ?? '5432'),
|
|
||||||
username: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASS,
|
|
||||||
database: process.env.DB_NAME ?? 'jellyseerr',
|
|
||||||
ssl: buildSslConfig(),
|
|
||||||
synchronize: false,
|
|
||||||
migrationsRun: false,
|
|
||||||
logging: boolFromEnv('DB_LOG_QUERIES'),
|
|
||||||
entities: ['dist/entity/**/*.js'],
|
|
||||||
migrations: ['dist/migration/postgres/**/*.js'],
|
|
||||||
subscribers: ['dist/subscriber/**/*.js'],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isPgsql = process.env.DB_TYPE === 'postgres';
|
|
||||||
|
|
||||||
function getDataSource(): DataSourceOptions {
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
return isPgsql ? postgresProdConfig : prodConfig;
|
|
||||||
} else {
|
|
||||||
return isPgsql ? postgresDevConfig : devConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataSource = new DataSource(getDataSource());
|
|
||||||
|
|
||||||
export const getRepository = <Entity extends object>(
|
export const getRepository = <Entity extends object>(
|
||||||
target: EntityTarget<Entity>
|
target: EntityTarget<Entity>
|
||||||
|
|||||||
@@ -80,12 +80,12 @@ export class Blacklist implements BlacklistItem {
|
|||||||
status: MediaStatus.BLACKLISTED,
|
status: MediaStatus.BLACKLISTED,
|
||||||
status4k: MediaStatus.BLACKLISTED,
|
status4k: MediaStatus.BLACKLISTED,
|
||||||
mediaType: blacklistRequest.mediaType,
|
mediaType: blacklistRequest.mediaType,
|
||||||
blacklist: Promise.resolve(blacklist),
|
blacklist: blacklist,
|
||||||
});
|
});
|
||||||
|
|
||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
} else {
|
} else {
|
||||||
media.blacklist = Promise.resolve(blacklist);
|
media.blacklist = blacklist;
|
||||||
media.status = MediaStatus.BLACKLISTED;
|
media.status = MediaStatus.BLACKLISTED;
|
||||||
media.status4k = MediaStatus.BLACKLISTED;
|
media.status4k = MediaStatus.BLACKLISTED;
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import type { DownloadingItem } from '@server/lib/downloadtracker';
|
|||||||
import downloadTracker from '@server/lib/downloadtracker';
|
import downloadTracker from '@server/lib/downloadtracker';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
|
||||||
import { getHostname } from '@server/utils/getHostname';
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import {
|
import {
|
||||||
AfterLoad,
|
AfterLoad,
|
||||||
@@ -43,10 +42,6 @@ class Media {
|
|||||||
finalIds = tmdbIds;
|
finalIds = tmdbIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (finalIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const media = await mediaRepository
|
const media = await mediaRepository
|
||||||
.createQueryBuilder('media')
|
.createQueryBuilder('media')
|
||||||
.leftJoinAndSelect(
|
.leftJoinAndSelect(
|
||||||
@@ -123,8 +118,10 @@ class Media {
|
|||||||
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
|
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
|
||||||
public issues: Issue[];
|
public issues: Issue[];
|
||||||
|
|
||||||
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
|
@OneToOne(() => Blacklist, (blacklist) => blacklist.media, {
|
||||||
public blacklist: Promise<Blacklist>;
|
eager: true,
|
||||||
|
})
|
||||||
|
public blacklist: Blacklist;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
@@ -132,23 +129,10 @@ class Media {
|
|||||||
@UpdateDateColumn()
|
@UpdateDateColumn()
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
/**
|
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
* The `lastSeasonChange` column stores the date and time when the media was added to the library.
|
|
||||||
* It needs to be database-aware because SQLite supports `datetime` while PostgreSQL supports `timestamp with timezone (timestampz)`.
|
|
||||||
*/
|
|
||||||
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
|
||||||
public lastSeasonChange: Date;
|
public lastSeasonChange: Date;
|
||||||
|
|
||||||
/**
|
@Column({ type: 'datetime', nullable: true })
|
||||||
* The `mediaAddedAt` column stores the date and time when the media was added to the library.
|
|
||||||
* It needs to be database-aware because SQLite supports `datetime` while PostgreSQL supports `timestamp with timezone (timestampz)`.
|
|
||||||
* This column is nullable because it can be null when the media is not yet synced to the library.
|
|
||||||
*/
|
|
||||||
@DbAwareColumn({
|
|
||||||
type: 'datetime',
|
|
||||||
default: () => 'CURRENT_TIMESTAMP',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
public mediaAddedAt: Date;
|
public mediaAddedAt: Date;
|
||||||
|
|
||||||
@Column({ nullable: true, type: 'int' })
|
@Column({ nullable: true, type: 'int' })
|
||||||
|
|||||||
@@ -257,7 +257,9 @@ export class MediaRequest {
|
|||||||
>;
|
>;
|
||||||
const requestedSeasons =
|
const requestedSeasons =
|
||||||
requestBody.seasons === 'all'
|
requestBody.seasons === 'all'
|
||||||
? tmdbMediaShow.seasons.map((season) => season.season_number)
|
? tmdbMediaShow.seasons
|
||||||
|
.map((season) => season.season_number)
|
||||||
|
.filter((sn) => sn > 0)
|
||||||
: (requestBody.seasons as number[]);
|
: (requestBody.seasons as number[]);
|
||||||
let existingSeasons: number[] = [];
|
let existingSeasons: number[] = [];
|
||||||
|
|
||||||
@@ -385,7 +387,6 @@ export class MediaRequest {
|
|||||||
@ManyToOne(() => Media, (media) => media.requests, {
|
@ManyToOne(() => Media, (media) => media.requests, {
|
||||||
eager: true,
|
eager: true,
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
nullable: false,
|
|
||||||
})
|
})
|
||||||
public media: Media;
|
public media: Media;
|
||||||
|
|
||||||
@@ -858,7 +859,7 @@ export class MediaRequest {
|
|||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
this.status = MediaRequestStatus.FAILED;
|
this.status = MediaRequestStatus.FAILED;
|
||||||
await requestRepository.save(this);
|
requestRepository.save(this);
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
||||||
@@ -1133,14 +1134,13 @@ export class MediaRequest {
|
|||||||
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||||
sonarrSeries.titleSlug;
|
sonarrSeries.titleSlug;
|
||||||
media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id;
|
media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id;
|
||||||
|
|
||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
})
|
})
|
||||||
.catch(async () => {
|
.catch(async () => {
|
||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
this.status = MediaRequestStatus.FAILED;
|
this.status = MediaRequestStatus.FAILED;
|
||||||
await requestRepository.save(this);
|
requestRepository.save(this);
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Something went wrong sending series request to Sonarr, marking status as FAILED',
|
'Something went wrong sending series request to Sonarr, marking status as FAILED',
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ class Season {
|
|||||||
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
||||||
public status4k: MediaStatus;
|
public status4k: MediaStatus;
|
||||||
|
|
||||||
@ManyToOne(() => Media, (media) => media.seasons, {
|
@ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' })
|
||||||
onDelete: 'CASCADE',
|
|
||||||
nullable: false,
|
|
||||||
})
|
|
||||||
public media: Promise<Media>;
|
public media: Promise<Media>;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ export class Watchlist implements WatchlistItem {
|
|||||||
@ManyToOne(() => Media, (media) => media.watchlists, {
|
@ManyToOne(() => Media, (media) => media.watchlists, {
|
||||||
eager: true,
|
eager: true,
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
nullable: false,
|
|
||||||
})
|
})
|
||||||
public media: Media;
|
public media: Media;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import PlexAPI from '@server/api/plexapi';
|
import PlexAPI from '@server/api/plexapi';
|
||||||
import dataSource, { getRepository, isPgsql } from '@server/datasource';
|
import dataSource, { getRepository } from '@server/datasource';
|
||||||
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
import { Session } from '@server/entity/Session';
|
import { Session } from '@server/entity/Session';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
@@ -35,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()}`);
|
||||||
@@ -66,13 +59,9 @@ app
|
|||||||
|
|
||||||
// Run migrations in production
|
// Run migrations in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
if (isPgsql) {
|
await dbConnection.query('PRAGMA foreign_keys=OFF');
|
||||||
await dbConnection.runMigrations();
|
await dbConnection.runMigrations();
|
||||||
} else {
|
await dbConnection.query('PRAGMA foreign_keys=ON');
|
||||||
await dbConnection.query('PRAGMA foreign_keys=OFF');
|
|
||||||
await dbConnection.runMigrations();
|
|
||||||
await dbConnection.query('PRAGMA foreign_keys=ON');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load Settings
|
// Load Settings
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { MediaServerType } from '@server/constants/server';
|
|||||||
import availabilitySync from '@server/lib/availabilitySync';
|
import availabilitySync from '@server/lib/availabilitySync';
|
||||||
import downloadTracker from '@server/lib/downloadtracker';
|
import downloadTracker from '@server/lib/downloadtracker';
|
||||||
import ImageProxy from '@server/lib/imageproxy';
|
import ImageProxy from '@server/lib/imageproxy';
|
||||||
import refreshToken from '@server/lib/refreshToken';
|
|
||||||
import {
|
import {
|
||||||
jellyfinFullScanner,
|
jellyfinFullScanner,
|
||||||
jellyfinRecentScanner,
|
jellyfinRecentScanner,
|
||||||
@@ -14,6 +13,7 @@ import type { JobId } from '@server/lib/settings';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import watchlistSync from '@server/lib/watchlistsync';
|
import watchlistSync from '@server/lib/watchlistsync';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import random from 'lodash/random';
|
||||||
import schedule from 'node-schedule';
|
import schedule from 'node-schedule';
|
||||||
|
|
||||||
interface ScheduledJob {
|
interface ScheduledJob {
|
||||||
@@ -113,20 +113,30 @@ export const startJobs = (): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Watchlist Sync
|
// Watchlist Sync
|
||||||
scheduledJobs.push({
|
const watchlistSyncJob: ScheduledJob = {
|
||||||
id: 'plex-watchlist-sync',
|
id: 'plex-watchlist-sync',
|
||||||
name: 'Plex Watchlist Sync',
|
name: 'Plex Watchlist Sync',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'seconds',
|
interval: 'fixed',
|
||||||
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
|
job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => {
|
||||||
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
});
|
});
|
||||||
watchlistSync.syncWatchlist();
|
watchlistSync.syncWatchlist();
|
||||||
}),
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// To help alleviate load on Plex's servers, we will add some fuzziness to the next schedule
|
||||||
|
// after each run
|
||||||
|
watchlistSyncJob.job.on('run', () => {
|
||||||
|
watchlistSyncJob.job.schedule(
|
||||||
|
new Date(Math.floor(Date.now() + 1000 * 60 * random(14, 24, true)))
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
scheduledJobs.push(watchlistSyncJob);
|
||||||
|
|
||||||
// Run full radarr scan every 24 hours
|
// Run full radarr scan every 24 hours
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'radarr-scan',
|
id: 'radarr-scan',
|
||||||
@@ -223,19 +233,5 @@ export const startJobs = (): void => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
scheduledJobs.push({
|
|
||||||
id: 'plex-refresh-token',
|
|
||||||
name: 'Plex Refresh Token',
|
|
||||||
type: 'process',
|
|
||||||
interval: 'fixed',
|
|
||||||
cronSchedule: jobs['plex-refresh-token'].schedule,
|
|
||||||
job: schedule.scheduleJob(jobs['plex-refresh-token'].schedule, () => {
|
|
||||||
logger.info('Starting scheduled job: Plex Refresh Token', {
|
|
||||||
label: 'Jobs',
|
|
||||||
});
|
|
||||||
refreshToken.run();
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
|
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ export type AvailableCacheIds =
|
|||||||
| 'imdb'
|
| 'imdb'
|
||||||
| 'github'
|
| 'github'
|
||||||
| 'plexguid'
|
| 'plexguid'
|
||||||
| 'plextv'
|
| 'plextv';
|
||||||
| 'plexwatchlist';
|
|
||||||
|
|
||||||
const DEFAULT_TTL = 300;
|
const DEFAULT_TTL = 300;
|
||||||
const DEFAULT_CHECK_PERIOD = 120;
|
const DEFAULT_CHECK_PERIOD = 120;
|
||||||
@@ -69,7 +68,6 @@ class CacheManager {
|
|||||||
stdTtl: 86400 * 7, // 1 week cache
|
stdTtl: 86400 * 7, // 1 week cache
|
||||||
checkPeriod: 60,
|
checkPeriod: 60,
|
||||||
}),
|
}),
|
||||||
plexwatchlist: new Cache('plexwatchlist', 'Plex Watchlist'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public getCache(id: AvailableCacheIds): Cache {
|
public getCache(id: AvailableCacheIds): Cache {
|
||||||
|
|||||||
@@ -106,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}`,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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 mime from 'mime/lite';
|
||||||
@@ -131,33 +131,29 @@ class ImageProxy {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetch: typeof fetch;
|
private axios;
|
||||||
private cacheVersion;
|
private cacheVersion;
|
||||||
private key;
|
private key;
|
||||||
private baseUrl;
|
|
||||||
private headers: HeadersInit | null = null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
key: string,
|
key: string,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
options: {
|
options: {
|
||||||
cacheVersion?: number;
|
cacheVersion?: number;
|
||||||
rateLimitOptions?: RateLimitOptions;
|
rateLimitOptions?: rateLimitOptions;
|
||||||
headers?: HeadersInit;
|
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;
|
|
||||||
}
|
}
|
||||||
this.headers = options.headers || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getImage(
|
public async getImage(
|
||||||
@@ -249,34 +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.length > 0
|
|
||||||
? this.baseUrl.endsWith('/')
|
|
||||||
? ''
|
|
||||||
: '/'
|
|
||||||
: '') +
|
|
||||||
(path.startsWith('/') ? path.slice(1) : path);
|
|
||||||
const response = await this.fetch(href, {
|
|
||||||
headers: this.headers || undefined,
|
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
const buffer = Buffer.from(response.data, 'binary');
|
||||||
}
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
|
||||||
|
|
||||||
const extension = mime.getExtension(
|
const extension = mime.getExtension(
|
||||||
response.headers.get('content-type') ?? ''
|
response.headers['Content-Type']?.toString() ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
let maxAge = Number(
|
let maxAge = Number(
|
||||||
(response.headers.get('cache-control') ?? '0').split('=')[1]
|
(response.headers['Cache-Control']?.toString() ?? '0').split('=')[1]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!maxAge) maxAge = 86400;
|
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,
|
||||||
|
|||||||
@@ -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,27 +292,14 @@ class DiscordAgent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.options.webhookRoleId) {
|
await axios.post(settings.options.webhookUrl, {
|
||||||
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
|
username: settings.options.botUsername
|
||||||
}
|
? settings.options.botUsername
|
||||||
|
: getSettings().main.applicationTitle,
|
||||||
const response = await fetch(settings.options.webhookUrl, {
|
avatar_url: settings.options.botAvatarUrl,
|
||||||
method: 'POST',
|
embeds: [this.buildEmbed(type, payload)],
|
||||||
headers: {
|
content: userMentions.join(' '),
|
||||||
'Content-Type': 'application/json',
|
} as DiscordWebhookPayload);
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: settings.options.botUsername
|
|
||||||
? settings.options.botUsername
|
|
||||||
: 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) {
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import PlexTvAPI from '@server/api/plextv';
|
|
||||||
import { getRepository } from '@server/datasource';
|
|
||||||
import { User } from '@server/entity/User';
|
|
||||||
import logger from '@server/logger';
|
|
||||||
|
|
||||||
class RefreshToken {
|
|
||||||
public async run() {
|
|
||||||
const userRepository = getRepository(User);
|
|
||||||
|
|
||||||
const users = await userRepository
|
|
||||||
.createQueryBuilder('user')
|
|
||||||
.addSelect('user.plexToken')
|
|
||||||
.where("user.plexToken != ''")
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
for (const user of users) {
|
|
||||||
await this.refreshUserToken(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async refreshUserToken(user: User) {
|
|
||||||
if (!user.plexToken) {
|
|
||||||
logger.warn('Skipping user refresh token for user without plex token', {
|
|
||||||
label: 'Plex Refresh Token',
|
|
||||||
user: user.displayName,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const plexTvApi = new PlexTvAPI(user.plexToken);
|
|
||||||
plexTvApi.pingToken();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshToken = new RefreshToken();
|
|
||||||
|
|
||||||
export default refreshToken;
|
|
||||||
@@ -278,7 +278,9 @@ class PlexScanner
|
|||||||
const seasons = tvShow.seasons;
|
const seasons = tvShow.seasons;
|
||||||
const processableSeasons: ProcessableSeason[] = [];
|
const processableSeasons: ProcessableSeason[] = [];
|
||||||
|
|
||||||
for (const season of seasons) {
|
const filteredSeasons = seasons.filter((sn) => sn.season_number !== 0);
|
||||||
|
|
||||||
|
for (const season of filteredSeasons) {
|
||||||
const matchedPlexSeason = metadata.Children?.Metadata.find(
|
const matchedPlexSeason = metadata.Children?.Metadata.find(
|
||||||
(md) => Number(md.index) === season.season_number
|
(md) => Number(md.index) === season.season_number
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -103,8 +103,10 @@ class SonarrScanner
|
|||||||
|
|
||||||
const tmdbId = tvShow.id;
|
const tmdbId = tvShow.id;
|
||||||
|
|
||||||
const filteredSeasons = sonarrSeries.seasons.filter((sn) =>
|
const filteredSeasons = sonarrSeries.seasons.filter(
|
||||||
tvShow.seasons.find((s) => s.season_number === sn.seasonNumber)
|
(sn) =>
|
||||||
|
sn.seasonNumber !== 0 &&
|
||||||
|
tvShow.seasons.find((s) => s.season_number === sn.seasonNumber)
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const season of filteredSeasons) {
|
for (const season of filteredSeasons) {
|
||||||
|
|||||||
@@ -170,7 +170,6 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig {
|
|||||||
botUsername?: string;
|
botUsername?: string;
|
||||||
botAvatarUrl?: string;
|
botAvatarUrl?: string;
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
webhookRoleId?: string;
|
|
||||||
enableMentions: boolean;
|
enableMentions: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -282,7 +281,6 @@ export type JobId =
|
|||||||
| 'plex-recently-added-scan'
|
| 'plex-recently-added-scan'
|
||||||
| 'plex-full-scan'
|
| 'plex-full-scan'
|
||||||
| 'plex-watchlist-sync'
|
| 'plex-watchlist-sync'
|
||||||
| 'plex-refresh-token'
|
|
||||||
| 'radarr-scan'
|
| 'radarr-scan'
|
||||||
| 'sonarr-scan'
|
| 'sonarr-scan'
|
||||||
| 'download-sync'
|
| 'download-sync'
|
||||||
@@ -396,7 +394,6 @@ class Settings {
|
|||||||
types: 0,
|
types: 0,
|
||||||
options: {
|
options: {
|
||||||
webhookUrl: '',
|
webhookUrl: '',
|
||||||
webhookRoleId: '',
|
|
||||||
enableMentions: true,
|
enableMentions: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -470,10 +467,7 @@ class Settings {
|
|||||||
schedule: '0 0 3 * * *',
|
schedule: '0 0 3 * * *',
|
||||||
},
|
},
|
||||||
'plex-watchlist-sync': {
|
'plex-watchlist-sync': {
|
||||||
schedule: '0 */3 * * * *',
|
schedule: '0 */10 * * * *',
|
||||||
},
|
|
||||||
'plex-refresh-token': {
|
|
||||||
schedule: '0 0 5 * * *',
|
|
||||||
},
|
},
|
||||||
'radarr-scan': {
|
'radarr-scan': {
|
||||||
schedule: '0 0 4 * * *',
|
schedule: '0 0 4 * * *',
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class WatchlistSync {
|
|||||||
|
|
||||||
const plexTvApi = new PlexTvAPI(user.plexToken);
|
const plexTvApi = new PlexTvAPI(user.plexToken);
|
||||||
|
|
||||||
const response = await plexTvApi.getWatchlist({ size: 20 });
|
const response = await plexTvApi.getWatchlist({ size: 200 });
|
||||||
|
|
||||||
const mediaItems = await Media.getRelatedMedia(
|
const mediaItems = await Media.getRelatedMedia(
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -1,304 +0,0 @@
|
|||||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class InitialMigration1705599190375 implements MigrationInterface {
|
|
||||||
name = 'InitialMigration1705599190375';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(
|
|
||||||
`create table if not exists session
|
|
||||||
(
|
|
||||||
"expiredAt" bigint,
|
|
||||||
id text,
|
|
||||||
json text
|
|
||||||
);`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create index if not exists "idx_194703_IDX_28c5d1d16da7908c97c9bc2f74"
|
|
||||||
on session ("expiredAt");`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create unique index if not exists idx_194703_sqlite_autoindex_session_1
|
|
||||||
on session (id);`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create table if not exists media
|
|
||||||
(
|
|
||||||
id serial,
|
|
||||||
"mediaType" text,
|
|
||||||
"tmdbId" int,
|
|
||||||
"tvdbId" int,
|
|
||||||
"imdbId" text,
|
|
||||||
status int default '1'::int,
|
|
||||||
status4k int default '1'::int,
|
|
||||||
"createdAt" timestamp with time zone default CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" timestamp with time zone default CURRENT_TIMESTAMP,
|
|
||||||
"lastSeasonChange" timestamp with time zone default CURRENT_TIMESTAMP,
|
|
||||||
"mediaAddedAt" timestamp with time zone,
|
|
||||||
"serviceId" int,
|
|
||||||
"serviceId4k" int,
|
|
||||||
"externalServiceId" int,
|
|
||||||
"externalServiceId4k" int,
|
|
||||||
"externalServiceSlug" text,
|
|
||||||
"externalServiceSlug4k" text,
|
|
||||||
"ratingKey" text,
|
|
||||||
"ratingKey4k" text,
|
|
||||||
"jellyfinMediaId" text,
|
|
||||||
"jellyfinMediaId4k" text,
|
|
||||||
constraint idx_194722_media_pkey
|
|
||||||
primary key (id)
|
|
||||||
);`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create table if not exists season
|
|
||||||
(
|
|
||||||
id serial,
|
|
||||||
"seasonNumber" int,
|
|
||||||
status int default '1'::int,
|
|
||||||
"createdAt" timestamp with time zone default CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" timestamp with time zone default CURRENT_TIMESTAMP,
|
|
||||||
"mediaId" int not null,
|
|
||||||
status4k int default '1'::int,
|
|
||||||
constraint idx_194715_season_pkey
|
|
||||||
primary key (id),
|
|
||||||
foreign key ("mediaId") references media
|
|
||||||
on delete cascade
|
|
||||||
);`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create index if not exists "idx_194722_IDX_7ff2d11f6a83cb52386eaebe74"
|
|
||||||
on media ("imdbId");`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create index if not exists "idx_194722_IDX_41a289eb1fa489c1bc6f38d9c3"
|
|
||||||
on media ("tvdbId");`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create index if not exists "idx_194722_IDX_7157aad07c73f6a6ae3bbd5ef5"
|
|
||||||
on media ("tmdbId");`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create unique index if not exists idx_194722_sqlite_autoindex_media_1
|
|
||||||
on media ("tvdbId");`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create table if not exists "user"
|
|
||||||
(
|
|
||||||
id serial,
|
|
||||||
email text,
|
|
||||||
username text,
|
|
||||||
"plexId" int,
|
|
||||||
"plexToken" text,
|
|
||||||
permissions int default '0'::int,
|
|
||||||
avatar text,
|
|
||||||
"createdAt" timestamp with time zone default CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" timestamp with time zone default CURRENT_TIMESTAMP,
|
|
||||||
password text,
|
|
||||||
"userType" int default '1'::int,
|
|
||||||
"plexUsername" text,
|
|
||||||
"resetPasswordGuid" text,
|
|
||||||
"recoveryLinkExpirationDate" date,
|
|
||||||
"movieQuotaLimit" int,
|
|
||||||
"movieQuotaDays" int,
|
|
||||||
"tvQuotaLimit" int,
|
|
||||||
"tvQuotaDays" int,
|
|
||||||
"jellyfinUsername" text,
|
|
||||||
"jellyfinAuthToken" text,
|
|
||||||
"jellyfinUserId" text,
|
|
||||||
"jellyfinDeviceId" text,
|
|
||||||
constraint idx_194731_user_pkey
|
|
||||||
primary key (id)
|
|
||||||
);`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create unique index if not exists idx_194731_sqlite_autoindex_user_1
|
|
||||||
on "user" (email);`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create table if not exists user_push_subscription
|
|
||||||
(
|
|
||||||
id serial,
|
|
||||||
endpoint text,
|
|
||||||
p256dh text,
|
|
||||||
auth text,
|
|
||||||
"userId" int,
|
|
||||||
constraint idx_194740_user_push_subscription_pkey
|
|
||||||
primary key (id),
|
|
||||||
foreign key ("userId") references "user"
|
|
||||||
on delete cascade
|
|
||||||
);`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create unique index if not exists idx_194740_sqlite_autoindex_user_push_subscription_1
|
|
||||||
on user_push_subscription (auth);`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create table if not exists issue
|
|
||||||
(
|
|
||||||
id serial,
|
|
||||||
"issueType" int,
|
|
||||||
status int default '1'::int,
|
|
||||||
"problemSeason" int default '0'::int,
|
|
||||||
"problemEpisode" int default '0'::int,
|
|
||||||
"createdAt" timestamp with time zone default CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" timestamp with time zone default CURRENT_TIMESTAMP,
|
|
||||||
"mediaId" int not null,
|
|
||||||
"createdById" int,
|
|
||||||
"modifiedById" int,
|
|
||||||
constraint idx_194747_issue_pkey
|
|
||||||
primary key (id),
|
|
||||||
foreign key ("modifiedById") references "user"
|
|
||||||
on delete cascade,
|
|
||||||
foreign key ("createdById") references "user"
|
|
||||||
on delete cascade,
|
|
||||||
foreign key ("mediaId") references media
|
|
||||||
on delete cascade
|
|
||||||
);`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create table if not exists issue_comment
|
|
||||||
(
|
|
||||||
id serial,
|
|
||||||
message text,
|
|
||||||
"createdAt" timestamp with time zone default CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" timestamp with time zone default CURRENT_TIMESTAMP,
|
|
||||||
"userId" int,
|
|
||||||
"issueId" int,
|
|
||||||
constraint idx_194755_issue_comment_pkey
|
|
||||||
primary key (id),
|
|
||||||
foreign key ("issueId") references issue
|
|
||||||
on delete cascade,
|
|
||||||
foreign key ("userId") references "user"
|
|
||||||
on delete cascade
|
|
||||||
);`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create table if not exists user_settings
|
|
||||||
(
|
|
||||||
id serial,
|
|
||||||
"notificationTypes" text,
|
|
||||||
"discordId" text,
|
|
||||||
"userId" int,
|
|
||||||
region text,
|
|
||||||
"originalLanguage" text,
|
|
||||||
"telegramChatId" text,
|
|
||||||
"telegramSendSilently" boolean,
|
|
||||||
"pgpKey" text,
|
|
||||||
locale text default ''::text,
|
|
||||||
"pushbulletAccessToken" text,
|
|
||||||
"pushoverApplicationToken" text,
|
|
||||||
"pushoverUserKey" text,
|
|
||||||
"watchlistSyncMovies" boolean,
|
|
||||||
"watchlistSyncTv" boolean,
|
|
||||||
"pushoverSound" varchar,
|
|
||||||
constraint idx_194762_user_settings_pkey
|
|
||||||
primary key (id),
|
|
||||||
foreign key ("userId") references "user"
|
|
||||||
on delete cascade
|
|
||||||
);`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create unique index if not exists idx_194762_sqlite_autoindex_user_settings_1
|
|
||||||
on user_settings ("userId");`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create table if not exists media_request
|
|
||||||
(
|
|
||||||
id serial,
|
|
||||||
status int,
|
|
||||||
"createdAt" timestamp with time zone default CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" timestamp with time zone default CURRENT_TIMESTAMP,
|
|
||||||
type text,
|
|
||||||
"mediaId" int not null,
|
|
||||||
"requestedById" int,
|
|
||||||
"modifiedById" int,
|
|
||||||
is4k boolean default false,
|
|
||||||
"serverId" int,
|
|
||||||
"profileId" int,
|
|
||||||
"rootFolder" text,
|
|
||||||
"languageProfileId" int,
|
|
||||||
tags text,
|
|
||||||
"isAutoRequest" boolean default false,
|
|
||||||
constraint idx_194770_media_request_pkey
|
|
||||||
primary key (id),
|
|
||||||
foreign key ("modifiedById") references "user"
|
|
||||||
on delete set null,
|
|
||||||
foreign key ("requestedById") references "user"
|
|
||||||
on delete cascade,
|
|
||||||
foreign key ("mediaId") references media
|
|
||||||
on delete cascade
|
|
||||||
);`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create table if not exists season_request
|
|
||||||
(
|
|
||||||
id serial NOT NULL,
|
|
||||||
"seasonNumber" int,
|
|
||||||
status int default '1'::int,
|
|
||||||
"createdAt" timestamp with time zone default now(),
|
|
||||||
"updatedAt" timestamp with time zone default now(),
|
|
||||||
"requestId" int,
|
|
||||||
constraint idx_194709_season_request_pkey
|
|
||||||
primary key (id),
|
|
||||||
foreign key ("requestId") references media_request
|
|
||||||
on delete cascade
|
|
||||||
);`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create table if not exists discover_slider
|
|
||||||
(
|
|
||||||
id serial,
|
|
||||||
type integer,
|
|
||||||
"order" integer,
|
|
||||||
"isBuiltIn" boolean default false,
|
|
||||||
enabled boolean default true,
|
|
||||||
title text,
|
|
||||||
data text,
|
|
||||||
"createdAt" timestamp with time zone default CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" timestamp with time zone default CURRENT_TIMESTAMP,
|
|
||||||
constraint idx_194779_discover_slider_pkey
|
|
||||||
primary key (id)
|
|
||||||
);`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create table if not exists watchlist
|
|
||||||
(
|
|
||||||
id serial,
|
|
||||||
"ratingKey" text,
|
|
||||||
"mediaType" text,
|
|
||||||
title text,
|
|
||||||
"tmdbId" int,
|
|
||||||
"createdAt" timestamp with time zone default CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" timestamp with time zone default CURRENT_TIMESTAMP,
|
|
||||||
"requestedById" int,
|
|
||||||
"mediaId" int not null,
|
|
||||||
constraint idx_194788_watchlist_pkey
|
|
||||||
primary key (id)
|
|
||||||
);`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create index if not exists "idx_194788_IDX_939f205946256cc0d2a1ac51a8"
|
|
||||||
on watchlist ("tmdbId");`
|
|
||||||
);
|
|
||||||
await queryRunner.query(
|
|
||||||
`create unique index if not exists idx_194788_sqlite_autoindex_watchlist_1
|
|
||||||
on watchlist ("tmdbId", "requestedById");`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`drop table if exists session cascade`);
|
|
||||||
await queryRunner.query(`drop table if exists season_request cascade`);
|
|
||||||
await queryRunner.query(`drop table if exists season cascade`);
|
|
||||||
await queryRunner.query(
|
|
||||||
`drop table if exists user_push_subscription cascade`
|
|
||||||
);
|
|
||||||
await queryRunner.query(`drop table if exists issue_comment cascade`);
|
|
||||||
await queryRunner.query(`drop table if exists issue cascade`);
|
|
||||||
await queryRunner.query(`drop table if exists user_settings cascade`);
|
|
||||||
await queryRunner.query(`drop table if exists media_request cascade`);
|
|
||||||
await queryRunner.query(`drop table if exists media cascade`);
|
|
||||||
await queryRunner.query(`drop table if exists "user" cascade`);
|
|
||||||
await queryRunner.query(`drop table if exists discover_slider cascade`);
|
|
||||||
await queryRunner.query(`drop table if exists watchlist cascade`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class AddBlacklist1730770837441 implements MigrationInterface {
|
|
||||||
name = 'AddBlacklist1730770837441';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(
|
|
||||||
`CREATE TABLE "blacklist"
|
|
||||||
(
|
|
||||||
"id" SERIAL PRIMARY KEY,
|
|
||||||
"mediaType" VARCHAR NOT NULL,
|
|
||||||
"title" VARCHAR,
|
|
||||||
"tmdbId" INTEGER NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
||||||
"userId" INTEGER,
|
|
||||||
"mediaId" INTEGER,
|
|
||||||
CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId", "userId")
|
|
||||||
)`
|
|
||||||
);
|
|
||||||
|
|
||||||
await queryRunner.query(
|
|
||||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId")`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(
|
|
||||||
`DROP INDEX IF EXISTS "IDX_6bbafa28411e6046421991ea21"`
|
|
||||||
);
|
|
||||||
await queryRunner.query(`DROP TABLE IF EXISTS "blacklist"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,14 @@ import { MediaType } from '@server/constants/media';
|
|||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import { Blacklist } from '@server/entity/Blacklist';
|
import { Blacklist } from '@server/entity/Blacklist';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
|
import { NotFoundError } from '@server/entity/Watchlist';
|
||||||
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
|
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { EntityNotFoundError, QueryFailedError } from 'typeorm';
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { QueryFailedError } from 'typeorm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const blacklistRoutes = Router();
|
const blacklistRoutes = Router();
|
||||||
@@ -24,6 +26,7 @@ blacklistRoutes.get(
|
|||||||
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
|
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
}),
|
}),
|
||||||
|
rateLimit({ windowMs: 60 * 1000, max: 50 }),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
const pageSize = req.query.take ? Number(req.query.take) : 25;
|
const pageSize = req.query.take ? Number(req.query.take) : 25;
|
||||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||||
@@ -68,32 +71,6 @@ blacklistRoutes.get(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
blacklistRoutes.get(
|
|
||||||
'/:id',
|
|
||||||
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
|
||||||
type: 'or',
|
|
||||||
}),
|
|
||||||
async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const blacklisteRepository = getRepository(Blacklist);
|
|
||||||
|
|
||||||
const blacklistItem = await blacklisteRepository.findOneOrFail({
|
|
||||||
where: { tmdbId: Number(req.params.id) },
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(200).send(blacklistItem);
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof EntityNotFoundError) {
|
|
||||||
return next({
|
|
||||||
status: 401,
|
|
||||||
message: e.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return next({ status: 500, message: e.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
blacklistRoutes.post(
|
blacklistRoutes.post(
|
||||||
'/',
|
'/',
|
||||||
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
||||||
@@ -157,7 +134,7 @@ blacklistRoutes.delete(
|
|||||||
|
|
||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof EntityNotFoundError) {
|
if (e instanceof NotFoundError) {
|
||||||
return next({
|
return next({
|
||||||
status: 401,
|
status: 401,
|
||||||
message: e.message,
|
message: e.message,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
requestStatus: statusFilter,
|
requestStatus: statusFilter,
|
||||||
})
|
})
|
||||||
.andWhere(
|
.andWhere(
|
||||||
'((request.is4k = false AND media.status IN (:...mediaStatus)) OR (request.is4k = true AND media.status4k IN (:...mediaStatus)))',
|
'((request.is4k = 0 AND media.status IN (:...mediaStatus)) OR (request.is4k = 1 AND media.status4k IN (:...mediaStatus)))',
|
||||||
{
|
{
|
||||||
mediaStatus: mediaStatusFilter,
|
mediaStatus: mediaStatusFilter,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ router.get('/', async (req, res, next) => {
|
|||||||
`CASE WHEN (user.username IS NULL OR user.username = '') THEN (
|
`CASE WHEN (user.username IS NULL OR user.username = '') THEN (
|
||||||
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
|
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
|
||||||
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
|
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
|
||||||
"user"."email"
|
user.email
|
||||||
ELSE
|
ELSE
|
||||||
LOWER(user.jellyfinUsername)
|
LOWER(user.jellyfinUsername)
|
||||||
END)
|
END)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ApiErrorCode } from '@server/constants/error';
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
import { UserType } from '@server/constants/user';
|
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import { UserSettings } from '@server/entity/UserSettings';
|
import { UserSettings } from '@server/entity/UserSettings';
|
||||||
@@ -100,29 +99,11 @@ userSettingsRoutes.post<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldEmail = user.email;
|
|
||||||
const oldUsername = user.username;
|
|
||||||
user.username = req.body.username;
|
user.username = req.body.username;
|
||||||
|
const oldEmail = user.email;
|
||||||
if (user.jellyfinUsername) {
|
if (user.jellyfinUsername) {
|
||||||
user.email = req.body.email || user.jellyfinUsername || user.email;
|
user.email = req.body.email || user.jellyfinUsername || user.email;
|
||||||
}
|
}
|
||||||
// Edge case for local users, because they have no Jellyfin username to fall back on
|
|
||||||
// if the email is not provided
|
|
||||||
if (user.userType === UserType.LOCAL) {
|
|
||||||
if (req.body.email) {
|
|
||||||
user.email = req.body.email;
|
|
||||||
if (
|
|
||||||
!user.username &&
|
|
||||||
user.email !== oldEmail &&
|
|
||||||
!oldEmail.includes('@')
|
|
||||||
) {
|
|
||||||
user.username = oldEmail;
|
|
||||||
}
|
|
||||||
} else if (req.body.username) {
|
|
||||||
user.email = oldUsername || user.email;
|
|
||||||
user.username = req.body.username;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingUser = await userRepository.findOne({
|
const existingUser = await userRepository.findOne({
|
||||||
where: { email: user.email },
|
where: { email: user.email },
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { isPgsql } from '@server/datasource';
|
|
||||||
import type { ColumnOptions, ColumnType } from 'typeorm';
|
|
||||||
import { Column } from 'typeorm';
|
|
||||||
const pgTypeMapping: { [key: string]: ColumnType } = {
|
|
||||||
datetime: 'timestamp with time zone',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function resolveDbType(pgType: ColumnType): ColumnType {
|
|
||||||
if (isPgsql && pgType.toString() in pgTypeMapping) {
|
|
||||||
return pgTypeMapping[pgType.toString()];
|
|
||||||
}
|
|
||||||
return pgType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DbAwareColumn(columnOptions: ColumnOptions) {
|
|
||||||
if (columnOptions.type) {
|
|
||||||
columnOptions.type = resolveDbType(columnOptions.type);
|
|
||||||
}
|
|
||||||
return Column(columnOptions);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user