Compare commits

..

1 Commits

Author SHA1 Message Date
gauthier-th
b92fe7821e feat: test force Ipv4 for Axios 2025-06-11 11:15:58 +02:00
125 changed files with 2075 additions and 5750 deletions

View File

@@ -4,16 +4,11 @@ dist/
config/
CHANGELOG.md
pnpm-lock.yaml
cypress/config/settings.cypress.json
# assets
src/assets/
public/
!public/sw.js
docs/
!/public/
/public/*
!/public/sw.js
# helm charts
**/charts

View File

@@ -21,11 +21,5 @@ module.exports = {
rangeEnd: 0, // default: Infinity
},
},
{
files: 'cypress/config/settings.cypress.json',
options: {
rangeEnd: 0,
},
},
],
};

View File

@@ -3,8 +3,8 @@ kubeVersion: ">=1.23.0-0"
name: jellyseerr-chart
description: Jellyseerr helm chart for Kubernetes
type: application
version: 2.6.2
appVersion: "2.7.3"
version: 2.5.0
appVersion: "2.6.0"
maintainers:
- name: Jellyseerr
url: https://github.com/Fallenbagel/jellyseerr

View File

@@ -1,6 +1,6 @@
# jellyseerr-chart
![Version: 2.6.2](https://img.shields.io/badge/Version-2.6.2-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.7.3](https://img.shields.io/badge/AppVersion-2.7.3-informational?style=flat-square)
![Version: 2.5.0](https://img.shields.io/badge/Version-2.5.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.6.0](https://img.shields.io/badge/AppVersion-2.6.0-informational?style=flat-square)
Jellyseerr helm chart for Kubernetes

View File

@@ -6,6 +6,7 @@
"apiKey": "testkey",
"applicationTitle": "Jellyseerr",
"applicationUrl": "",
"csrfProtection": false,
"cacheImages": false,
"defaultPermissions": 32,
"defaultQuotas": {
@@ -82,6 +83,13 @@
"enableMentions": true
}
},
"lunasea": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": ""
}
},
"slack": {
"enabled": false,
"types": 0,
@@ -179,26 +187,5 @@
"image-cache-cleanup": {
"schedule": "0 0 5 * * *"
}
},
"network": {
"csrfProtection": false,
"trustProxy": false,
"forceIpv4First": false,
"dnsServers": "",
"proxy": {
"enabled": false,
"hostname": "",
"port": 8080,
"useSsl": false,
"user": "",
"password": "",
"bypassFilter": "",
"bypassLocalAddresses": true
},
"dnsCache": {
"enabled": false,
"forceMinTtl": 0,
"forceMaxTtl": -1
}
}
}

View File

@@ -1,148 +0,0 @@
describe('TVDB Integration', () => {
// Constants for routes and selectors
const ROUTES = {
home: '/',
metadataSettings: '/settings/metadata',
tomorrowIsOursTvShow: '/tv/72879',
monsterTvShow: '/tv/225634',
dragonnBallZKaiAnime: '/tv/61709',
};
const SELECTORS = {
sidebarToggle: '[data-testid=sidebar-toggle]',
sidebarSettingsMobile: '[data-testid=sidebar-menu-settings-mobile]',
settingsNavDesktop: 'nav[data-testid="settings-nav-desktop"]',
metadataTestButton: 'button[type="button"]:contains("Test")',
metadataSaveButton: '[data-testid="metadata-save-button"]',
tmdbStatus: '[data-testid="tmdb-status"]',
tvdbStatus: '[data-testid="tvdb-status"]',
tvMetadataProviderSelector: '[data-testid="tv-metadata-provider-selector"]',
animeMetadataProviderSelector:
'[data-testid="anime-metadata-provider-selector"]',
seasonSelector: '[data-testid="season-selector"]',
season1: 'Season 1',
season2: 'Season 2',
season3: 'Season 3',
episodeList: '[data-testid="episode-list"]',
episode9: '9 - Hang Men',
};
// Reusable commands
const navigateToMetadataSettings = () => {
cy.visit(ROUTES.home);
cy.get(SELECTORS.sidebarToggle).click();
cy.get(SELECTORS.sidebarSettingsMobile).click();
cy.get(
`${SELECTORS.settingsNavDesktop} a[href="${ROUTES.metadataSettings}"]`
).click();
};
const testAndVerifyMetadataConnection = () => {
cy.intercept('POST', '/api/v1/settings/metadatas/test').as(
'testConnection'
);
cy.get(SELECTORS.metadataTestButton).click();
return cy.wait('@testConnection');
};
const saveMetadataSettings = (customBody = null) => {
if (customBody) {
cy.intercept('PUT', '/api/v1/settings/metadatas', (req) => {
req.body = customBody;
}).as('saveMetadata');
} else {
// Else just intercept without modifying body
cy.intercept('PUT', '/api/v1/settings/metadatas').as('saveMetadata');
}
cy.get(SELECTORS.metadataSaveButton).click();
return cy.wait('@saveMetadata');
};
beforeEach(() => {
// Perform login
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
// Navigate to Metadata settings
navigateToMetadataSettings();
// Verify we're on the correct settings page
cy.contains('h3', 'Metadata Providers').should('be.visible');
// Configure TVDB as TV provider and test connection
cy.get(SELECTORS.tvMetadataProviderSelector).click();
// get id react-select-4-option-1
cy.get('[class*="react-select__option"]').contains('TheTVDB').click();
// Test the connection
testAndVerifyMetadataConnection().then(({ response }) => {
expect(response.statusCode).to.equal(200);
// Check TVDB connection status
cy.get(SELECTORS.tvdbStatus).should('contain', 'Operational');
});
// Save settings
saveMetadataSettings({
anime: 'tvdb',
tv: 'tvdb',
}).then(({ response }) => {
expect(response.statusCode).to.equal(200);
expect(response.body.tv).to.equal('tvdb');
});
});
it('should display "Tomorrow is Ours" show information with multiple seasons from TVDB', () => {
// Navigate to the TV show
cy.visit(ROUTES.tomorrowIsOursTvShow);
// Verify that multiple seasons are displayed (TMDB has only 1 season, TVDB has multiple)
// cy.get(SELECTORS.seasonSelector).should('exist');
cy.intercept('/api/v1/tv/225634/season/1').as('season1');
// Select Season 2 and verify it loads
cy.contains(SELECTORS.season2)
.should('be.visible')
.scrollIntoView()
.click();
// Verify that episodes are displayed for Season 2
cy.contains('260 - Episode 506').should('be.visible');
});
it('Should display "Monster" show information correctly when not existing on TVDB', () => {
// Navigate to the TV show
cy.visit(ROUTES.monsterTvShow);
// Intercept season 1 request
cy.intercept('/api/v1/tv/225634/season/1').as('season1');
// Select Season 1
cy.contains(SELECTORS.season1)
.should('be.visible')
.scrollIntoView()
.click();
// Wait for the season data to load
cy.wait('@season1');
// Verify specific episode exists
cy.contains(SELECTORS.episode9).should('be.visible');
});
it('should display "Dragon Ball Z Kai" show information with multiple only 2 seasons from TVDB', () => {
// Navigate to the TV show
cy.visit(ROUTES.dragonnBallZKaiAnime);
// Intercept season 1 request
cy.intercept('/api/v1/tv/61709/season/1').as('season1');
// Select Season 2 and verify it visible
cy.contains(SELECTORS.season2)
.should('be.visible')
.scrollIntoView()
.click();
// select season 3 and verify it not visible
cy.contains(SELECTORS.season3).should('not.exist');
});
});

View File

@@ -12,7 +12,7 @@ Jellyseerr supports SQLite and PostgreSQL. The database connection can be config
If you want to use SQLite, you can simply set the `DB_TYPE` environment variable to `sqlite`. This is the default configuration so even if you don't set any other options, SQLite will be used.
```dotenv
DB_TYPE=sqlite # Which DB engine to use, either sqlite or postgres. The default is sqlite.
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".
```
@@ -24,7 +24,7 @@ DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging.
If your PostgreSQL server is configured to accept TCP connections, you can specify the host and port using the `DB_HOST` and `DB_PORT` environment variables. This is useful for remote connections where the server uses a network host and port.
```dotenv
DB_TYPE=postgres # Which DB engine to use, either sqlite or postgres. The default is sqlite.
DB_TYPE="postgres" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite".
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.
@@ -38,7 +38,7 @@ DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging.
If your PostgreSQL server is configured to accept Unix socket connections, you can specify the path to the socket directory using the `DB_SOCKET_PATH` environment variable. This is useful for local connections where the server uses a Unix socket.
```dotenv
DB_TYPE=postgres # Which DB engine to use, either sqlite or postgres. The default is sqlite.
DB_TYPE="postgres" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite".
DB_SOCKET_PATH="/var/run/postgresql" # (required) The path to the PostgreSQL Unix socket directory.
DB_USER= # (required) Username used to connect to the database.
DB_PASS= # (optional) Password of the user used to connect to the database, depending on the server's authentication configuration.
@@ -46,27 +46,6 @@ DB_NAME="jellyseerr" # (optional) The name of the database to connect to. The de
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
```
:::info
**Finding Your PostgreSQL Socket Path**
The PostgreSQL socket path varies by operating system and installation method:
- **Ubuntu/Debian**: `/var/run/postgresql`
- **CentOS/RHEL/Fedora**: `/var/run/postgresql`
- **macOS (Homebrew)**: `/tmp` or `/opt/homebrew/var/postgresql`
- **macOS (Postgres.app)**: `/tmp`
- **Windows**: Not applicable (uses TCP connections)
You can find your socket path by running:
```bash
# Find PostgreSQL socket directory
find /tmp /var/run /run -name ".s.PGSQL.*" 2>/dev/null | head -1 | xargs dirname
# Or check PostgreSQL configuration
sudo -u postgres psql -c "SHOW unix_socket_directories;"
```
:::
### 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.
@@ -77,11 +56,10 @@ DB_SSL_REJECT_UNAUTHORIZED="true" # (optional) Whether to reject ssl connections
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= # (optional) Path to the private key for the connection in PEM format. 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
@@ -90,76 +68,15 @@ DB_SSL_CERT_FILE= # (optional) Path to certificate chain in pem format for the p
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:
:::info
Edit the postgres connection string (without the \{\{ and \}\} brackets) to match your setup.
Edit the postgres connection string to match your setup.
If you 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.
:::
:::caution
The most recent release of pgloader has an issue quoting the table columns. Use the version in the docker container to avoid this issue.
:::
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
<Tabs>
<TabItem value="docker" label="Using pgloader Container (Recommended)" default>
**Recommended method**: Use the pgloader container even for standalone Jellyseerr installations. This avoids building from source and ensures compatibility.
```bash
# For standalone installations (no Docker network needed)
docker run --rm \
-v /path/to/your/config/db.sqlite3:/db.sqlite3:ro \
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}}
```
**For Docker Compose setups**: Add the network parameter if your PostgreSQL is also in a container:
```bash
docker run --rm \
--network your-jellyseerr-network \
-v /path/to/your/config/db.sqlite3:/db.sqlite3:ro \
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}}
```
</TabItem>
<TabItem value="standalone" label="Building pgloader from Source">
For users who prefer not to use Docker or need a custom build:
```bash
# Clone the repository and checkout the working version
git clone https://github.com/dimitri/pgloader.git
cd pgloader
git fetch origin pull/1531/head:pr-1531
git checkout pr-1531
# Follow the official installation instructions
# See: https://github.com/dimitri/pgloader/blob/master/INSTALL.md
```
:::info
**Building pgloader from source requires following the complete installation process outlined in the [official pgloader INSTALL.md](https://github.com/dimitri/pgloader/blob/master/INSTALL.md).**
Please refer to the official documentation for detailed, up-to-date installation instructions.
:::
Once pgloader is built, run the migration:
```bash
# Run migration (adjust path to your config directory)
./pgloader --with "quote identifiers" --with "data only" \
/path/to/your/config/db.sqlite3 \
postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}}
```
</TabItem>
</Tabs>
docker run --rm -v config/db.sqlite3:/db.sqlite3:ro 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

View File

@@ -207,62 +207,3 @@ labels:
```
For more information, please refer to the [Traefik documentation](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
## Apache2 HTTP Server
<Tabs groupId="apache2-reverse-proxy" queryString>
<TabItem value="subdomain" label="Subdomain">
Add the following Location block to your existing Server configuration.
```apache
# Jellyseerr
ProxyPreserveHost On
ProxyPass / http://localhost:5055 retry=0 connectiontimeout=5 timeout=30 keepalive=on
ProxyPassReverse http://localhost:5055 /
RequestHeader set Connection ""
```
</TabItem>
<TabItem value="subfolder" label="Subfolder">
:::warning
This Apache2 subfolder reverse proxy is an unsupported workaround, and only provided as an example. The filters may stop working when Jellyseerr is updated.
If you encounter any issues with Jellyseerr while using this workaround, we may ask you to try to reproduce the problem without the Apache2 proxy.
:::
Add the following Location block to your existing Server configuration.
```apache
# Jellyseerr
# We will use "/jellyseerr" as subfolder
# You can replace it with any that you like
<Location /jellyseerr>
ProxyPreserveHost On
ProxyPass http://localhost:5055 retry=0 connectiontimeout=5 timeout=30 keepalive=on
ProxyPassReverse http://localhost:5055
RequestHeader set Connection ""
# Header update, to support subfolder
# Please Replace "FQDN" with your domain
Header edit location ^/login https://FQDN/jellyseerr/login
Header edit location ^/setup https://FQDN/jellyseerr/setup
AddOutputFilterByType INFLATE;SUBSTITUTE text/html application/javascript application/json
SubstituteMaxLineLength 2000K
# This is HTML and JS update
# Please update "/jellyseerr" if needed
Substitute "s|href=\"|href=\"/jellyseerr|inq"
Substitute "s|src=\"|src=\"/jellyseerr|inq"
Substitute "s|/api/|/jellyseerr/api/|inq"
Substitute "s|\"/_next/|\"/jellyseerr/_next/|inq"
# This is JSON update
Substitute "s|\"/avatarproxy/|\"/jellyseerr/avatarproxy/|inq"
</Location>
```
</TabItem>
</Tabs>

View File

@@ -33,31 +33,20 @@ docker run -d \
--name jellyseerr \
-e LOG_LEVEL=debug \
-e TZ=Asia/Tashkent \
-e PORT=5055 \
-e PORT=5055 `#optional` \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--restart unless-stopped \
fallenbagel/jellyseerr
```
The argument `-e PORT=5055` is optional.
If you want to add a healthcheck to the above command, you can add the following flags :
```
--health-cmd "wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1" \
--health-start-period 20s \
--health-timeout 3s \
--health-interval 15s \
--health-retries 3 \
```
To run the container as a specific user/group, you may optionally add `--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ]` to the above command.
#### Updating:
Stop and remove the existing container:
```bash
docker stop jellyseerr && docker rm jellyseerr
docker stop jellyseerr && docker rm Jellyseerr
```
Pull the latest image:
```bash
@@ -94,12 +83,6 @@ services:
- 5055:5055
volumes:
- /path/to/appdata/config:/app/config
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1
start_period: 20s
timeout: 3s
interval: 15s
retries: 3
restart: unless-stopped
```
@@ -154,26 +137,7 @@ Then, create and start the Jellyseerr container:
<Tabs groupId="docker-methods" queryString>
<TabItem value="docker-cli" label="Docker CLI">
```bash
docker run -d \
--name jellyseerr \
-e LOG_LEVEL=debug \
-e TZ=Asia/Tashkent \
-e PORT=5055 \
-p 5055:5055 \
-v jellyseerr-data:/app/config \
--restart unless-stopped \
fallenbagel/jellyseerr
```
The argument `-e PORT=5055` is optional.
If you want to add a healthcheck to the above command, you can add the following flags :
```
--health-cmd "wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1" \
--health-start-period 20s \
--health-timeout 3s \
--health-interval 15s \
--health-retries 3 \
docker run -d --name jellyseerr -e LOG_LEVEL=debug -e TZ=Asia/Tashkent -p 5055:5055 -v "jellyseerr-data:/app/config" --restart unless-stopped fallenbagel/jellyseerr:latest
```
#### Updating:
@@ -201,12 +165,6 @@ services:
- 5055:5055
volumes:
- jellyseerr-data:/app/config
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1
start_period: 20s
timeout: 3s
interval: 15s
retries: 3
restart: unless-stopped
volumes:

View File

@@ -105,12 +105,6 @@ In some places (like China), the ISP blocks not only the DNS resolution but also
You can configure Jellyseerr to use a proxy with the [HTTP(S) Proxy](/using-jellyseerr/settings/general#https-proxy) setting.
### Option 3: Force IPV4 resolution first
Sometimes there are configuration issues with IPV6 that prevent the hostname resolution from working correctly.
You can try to force the resolution to use IPV4 first by going to `Settings > Networking > Advanced Networking` and enabling `Force IPv4 Resolution First` setting and restarting Jellyseerr.
### Option 4: Check that your server can reach TMDB API
Make sure that your server can reach the TMDB API by running the following command:

View File

@@ -1,21 +0,0 @@
---
title: Gotify
description: Configure Gotify notifications.
sidebar_position: 5
---
# Gotify
## Configuration
### Server URL
Set this to the URL of your Gotify server.
### Application Token
Add an application to your Gotify server, and set this field to the generated application token.
:::info
Please refer to the [Gotify API documentation](https://gotify.net/docs) for more details on configuring these notifications.
:::

View File

@@ -1,29 +0,0 @@
---
title: ntfy.sh
description: Configure ntfy.sh notifications.
sidebar_position: 6
---
# ntfy.sh
## Configuration
### Server Root URL
Set this to the URL of your ntfy.sh server.
### Topic
Set this to the topic you want to send notifications to.
### Username + Password authentication (optional)
Set this to the username and password for your ntfy.sh server.
### Token authentication (optional)
Set this to the token for your ntfy.sh server.
:::info
Please refer to the [ntfy.sh API documentation](https://docs.ntfy.sh/) for more details on configuring these notifications.
:::

View File

@@ -1,23 +0,0 @@
---
title: Pushbullet
description: Configure Pushbullet notifications.
sidebar_position: 7
---
# Pushbullet
:::info
Users can optionally configure personal notifications in their user settings.
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
:::
## Configuration
### Access Token
[Create an access token](https://www.pushbullet.com/#settings) and set it here to grant Jellyseerr access to the Pushbullet API.
### Channel Tag (optional)
Optionally, [create a channel](https://www.pushbullet.com/my-channel) to allow other users to follow the notification feed using the specified channel tag.

View File

@@ -1,27 +0,0 @@
---
title: Pushover
description: Configure Pushover notifications.
sidebar_position: 8
---
# Pushover
:::info
Users can optionally configure personal notifications in their user settings.
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
:::
## Configuration
### Application/API Token
[Register an application](https://pushover.net/apps/build) and enter the API token in this field. (You can use one of the [official icons in our GitHub repository](https://github.com/fallenbagel/jellyseerr/tree/develop/public) when configuring the application.)
For more details on registering applications or the API token, please see the [Pushover API documentation](https://pushover.net/api#registration).
### User Key
Set this to the user key for your Pushover account. Alternatively, you can set this to a group key to deliver notifications to multiple users.
For more details, please see the [Pushover API documentation](https://pushover.net/api#identifiers).

View File

@@ -1,17 +0,0 @@
---
title: Slack
description: Configure Slack notifications.
sidebar_position: 9
---
# Slack
## Configuration
### Webhook URL
Simply [create a webhook](https://my.slack.com/services/new/incoming-webhook/) and enter the URL in this field.
:::info
Please refer to the [Slack API documentation](https://api.slack.com/messaging/webhooks) for more details on configuring these notifications.
:::

View File

@@ -1,39 +0,0 @@
---
title: Telegram
description: Configure Telegram notifications.
sidebar_position: 10
---
# Telegram
:::info
Users can optionally configure personal notifications in their user settings.
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
:::
## Configuration
:::info
In order to configure Telegram notifications, you first need to [create a bot](https://telegram.me/BotFather).
Bots **cannot** initiate conversations with users, so users must have your bot added to a conversation in order to receive notifications.
:::
### Bot Username (optional)
If this value is configured, users will be able to click a link to start a chat with your bot and configure their own personal notifications.
The bot username should end with `_bot`, and the `@` prefix should be omitted.
### Bot Authentication Token
At the end of the bot creation process, [@BotFather](https://telegram.me/botfather) will provide an authentication token.
### Chat ID
To obtain your chat ID, simply create a new group chat, add [@get_id_bot](https://telegram.me/get_id_bot), and issue the `/my_id` command.
### Send Silently (optional)
Optionally, notifications can be sent silently. Silent notifications send messages without notification sounds.

View File

@@ -1,138 +0,0 @@
---
title: Webhook
description: Configure webhook notifications.
sidebar_position: 4
---
# Webhook
The webhook notification agent enables you to send a custom JSON payload to any endpoint for specific notification events.
## Configuration
### Webhook URL
The URL you would like to post notifications to. Your JSON will be sent as the body of the request.
### Authorization Header (optional)
:::info
This is typically not needed. Please refer to your webhook provider's documentation for details.
:::
This value will be sent as an `Authorization` HTTP header.
### JSON Payload
Customize the JSON payload to suit your needs. Jellyseerr provides several [template variables](#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered.
## Template Variables
### General
| Variable | Value |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `{{notification_type}}` | The type of notification (e.g. `MEDIA_PENDING` or `ISSUE_COMMENT`) |
| `{{event}}` | A friendly description of the notification event |
| `{{subject}}` | The notification subject (typically the media title) |
| `{{message}}` | The notification message body (the media overview/synopsis for request notifications; the issue description for issue notificatons) |
| `{{image}}` | The notification image (typically the media poster) |
### Notify User
These variables are for the target recipient of the notification.
| Variable | Value |
| ---------------------------------------- | ------------------------------------------------------------- |
| `{{notifyuser_username}}` | The target notification recipient's username |
| `{{notifyuser_email}}` | The target notification recipient's email address |
| `{{notifyuser_avatar}}` | The target notification recipient's avatar URL |
| `{{notifyuser_settings_discordId}}` | The target notification recipient's Discord ID (if set) |
| `{{notifyuser_settings_telegramChatId}}` | The target notification recipient's Telegram Chat ID (if set) |
:::info
The `notifyuser` variables are not defined for the following request notification types, as they are intended for application administrators rather than end users:
- Request Pending Approval
- Request Automatically Approved
- Request Processing Failed
On the other hand, the `notifyuser` variables _will_ be replaced with the requesting user's information for the below notification types:
- Request Approved
- Request Declined
- Request Available
If you would like to use the requesting user's information in your webhook, please instead include the relevant variables from the [Request](#request) section below.
:::
### Special
The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`).
| Variable | Value |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `{{media}}` | The relevant media object |
| `{{request}}` | The relevant request object |
| `{{issue}}` | The relevant issue object |
| `{{comment}}` | The relevant issue comment object |
| `{{extra}}` | The "extra" array of additional data for certain notifications (e.g., season/episode numbers for series-related notifications) |
#### Media
The `{{media}}` will be `null` if there is no relevant media object for the notification.
These following special variables are only included in media-related notifications, such as requests.
| Variable | Value |
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
| `{{media_type}}` | The media type (`movie` or `tv`) |
| `{{media_tmdbid}}` | The media's TMDB ID |
| `{{media_tvdbid}}` | The media's TheTVDB ID |
| `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
| `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
#### Request
The `{{request}}` will be `null` if there is no relevant media object for the notification.
The following special variables are only included in request-related notifications.
| Variable | Value |
| ----------------------------------------- | ----------------------------------------------- |
| `{{request_id}}` | The request ID |
| `{{requestedBy_username}}` | The requesting user's username |
| `{{requestedBy_email}}` | The requesting user's email address |
| `{{requestedBy_avatar}}` | The requesting user's avatar URL |
| `{{requestedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
| `{{requestedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
#### Issue
The `{{issue}}` will be `null` if there is no relevant media object for the notification.
The following special variables are only included in issue-related notifications.
| Variable | Value |
| ---------------------------------------- | ----------------------------------------------- |
| `{{issue_id}}` | The issue ID |
| `{{reportedBy_username}}` | The requesting user's username |
| `{{reportedBy_email}}` | The requesting user's email address |
| `{{reportedBy_avatar}}` | The requesting user's avatar URL |
| `{{reportedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
| `{{reportedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
#### Comment
The `{{comment}}` will be `null` if there is no relevant media object for the notification.
The following special variables are only included in issue comment-related notifications.
| Variable | Value |
| ----------------------------------------- | ----------------------------------------------- |
| `{{comment_message}}` | The comment message |
| `{{commentedBy_username}}` | The commenting user's username |
| `{{commentedBy_email}}` | The commenting user's email address |
| `{{commentedBy_avatar}}` | The commenting user's avatar URL |
| `{{commentedBy_settings_discordId}}` | The commenting user's Discord ID (if set) |
| `{{commentedBy_settings_telegramChatId}}` | The commenting user's Telegram Chat ID (if set) |

View File

@@ -1,10 +0,0 @@
{
"label": "Plex Integration",
"position": 3,
"link": {
"type": "generated-index",
"title": "Plex Integration",
"description": "Learn about Jellyseerr's Plex integration features"
}
}

View File

@@ -1,36 +0,0 @@
---
title: Overview
description: Learn about Jellyseerr's Plex integration features
sidebar_position: 1
---
# Plex Features Overview
Jellyseerr provides integration features that connect with your Plex media server to automate media management tasks.
## Available Features
- [Watchlist Auto Request](./plex/watchlist-auto-request) - Automatically request media from your Plex Watchlist
- More features coming soon!
## Prerequisites
:::info Authentication Required
To use any Plex integration features, you must have logged into Jellyseerr at least once with your Plex account.
:::
**Requirements:**
- Plex account with access to the configured Plex server
- Jellyseerr configured with Plex as the media server
- User authentication via Plex login
- Appropriate user permissions for specific features
## Getting Started
1. Authenticate at least once using your Plex credentials
2. Verify you have the necessary permissions for desired features
3. Follow individual feature guides for setup instructions
:::note Server Configuration
Plex server configuration is handled by your administrator. If you cannot log in with your Plex account, contact your administrator to verify the server setup.
:::

View File

@@ -1,95 +0,0 @@
---
title: Watchlist Auto Request
description: Learn how to use the Plex Watchlist Auto Request feature
sidebar_position: 1
---
# Watchlist Auto Request
The Plex Watchlist Auto Request feature allows Jellyseerr to automatically create requests for media items you add to your Plex Watchlist. Simply add content to your Plex Watchlist, and Jellyseerr will automatically request it for you.
:::info
This feature is only available for Plex users. Local users cannot use the Watchlist Auto Request feature.
:::
## Prerequisites
- You must have logged into Jellyseerr at least once with your Plex account
- Your administrator must have granted you the necessary permissions
- Your Plex account must have access to the Plex server configured in Jellyseerr
## Permission System
The Watchlist Auto Request feature uses a two-tier permission system:
### Administrator Permissions (Required)
Your administrator must grant you these permissions in your user profile:
- **Auto-Request** (master permission)
- **Auto-Request Movies** (for movie auto-requests)
- **Auto-Request Series** (for TV series auto-requests)
### User Activation (Required)
You must enable the feature in your own profile settings:
- **Auto-Request Movies** toggle
- **Auto-Request Series** toggle
:::warning Two-Step Process
Both administrator permissions AND user activation are required. Having permissions doesn't automatically enable the feature - you must also activate it in your profile.
:::
## How to Enable
### Step 1: Check Your Permissions
Contact your administrator to verify you have been granted:
- `Auto-Request` permission
- `Auto-Request Movies` and/or `Auto-Request Series` permissions
### Step 2: Activate the Feature
1. Go to your user profile settings
2. Navigate to the "General" section
3. Find the "Auto-Request" options
4. Enable the toggles for:
- **Auto-Request Movies** - to automatically request movies from your watchlist
- **Auto-Request Series** - to automatically request TV series from your watchlist
### Step 3: Start Using
- Add movies and TV shows to your Plex Watchlist
- Jellyseerr will automatically create requests for new items
- You'll receive notifications when items are auto-requested
## How It Works
Once properly configured, Jellyseerr will:
1. Periodically checks your Plex Watchlist for new items
2. Verify if the content already exists in your media libraries
3. Automatically submits requests for new items that aren't already available
4. Only requests content types you have permissions for
5. Notifiy you when auto-requests are created
:::info Content Limitations
Auto-request only works for standard quality content. 4K content must be requested manually if you have 4K permissions.
:::
## For Administrators
### Granting Permissions
1. Navigate to **Users** > **[Select User]** > **Permissions**
2. Enable the required permissions:
- **Auto-Request** (master toggle)
- **Auto-Request Movies** (for movie auto-requests)
- **Auto-Request Series** (for TV series auto-requests)
3. Optionally enable **Auto-Approve** permissions for automatic approval
### Default Permissions
- Go to **Settings** > **Users** > **Default Permissions**
- Configure auto-request permissions for new users
- This sets the default permissions but users still need to activate the feature individually
## Limitations
- Local users cannot use this feature
- 4K content requires manual requests
- Users must have logged into Jellyseerr with their Plex account
- Respects user request limits and quotas
- Won't request content already in your libraries

View File

@@ -1,16 +0,0 @@
---
title: DNS Caching
description: Configure DNS caching settings.
sidebar_position: 7
---
# DNS Caching
Jellyseerr uses DNS caching to improve performance and reduce the number of DNS lookups required for external API calls. This can help speed up response times and reduce load on DNS servers, when something like a Pi-hole is used as a DNS resolver.
## Configuration
You can enable the DNS caching settings in the Network tab of the Jellyseerr settings. The default values follow the standard DNS caching behavior.
- **Force Minimum TTL**: Set a minimum time-to-live (TTL) in seconds for DNS cache entries. This ensures that frequently accessed DNS records are cached for a longer period, reducing the need for repeated lookups. Default is 0.
- **Force Maximum TTL**: Set a maximum time-to-live (TTL) in seconds for DNS cache entries. This prevents infrequently accessed DNS records from being cached indefinitely, allowing for more up-to-date information to be retrieved. Default is -1 (unlimited).

View File

@@ -1,7 +1,6 @@
---
title: Jobs & Cache
description: Configure jobs and cache settings.
sidebar_position: 6
---
# Jobs & Cache

View File

@@ -141,83 +141,14 @@ components:
UserSettings:
type: object
properties:
username:
type: string
nullable: true
example: 'Mr User'
email:
type: string
example: 'user@example.com'
discordId:
type: string
nullable: true
example: '123456789'
locale:
type: string
nullable: true
example: 'en'
discoverRegion:
type: string
nullable: true
example: 'US'
streamingRegion:
type: string
nullable: true
example: 'US'
originalLanguage:
type: string
nullable: true
example: 'en'
movieQuotaLimit:
type: number
nullable: true
description: 'Maximum number of movie requests allowed'
example: 10
movieQuotaDays:
type: number
nullable: true
description: 'Time period in days for movie quota'
example: 30
tvQuotaLimit:
type: number
nullable: true
description: 'Maximum number of TV requests allowed'
example: 5
tvQuotaDays:
type: number
nullable: true
description: 'Time period in days for TV quota'
example: 14
globalMovieQuotaDays:
type: number
nullable: true
description: 'Global movie quota days setting'
example: 30
globalMovieQuotaLimit:
type: number
nullable: true
description: 'Global movie quota limit setting'
example: 10
globalTvQuotaLimit:
type: number
nullable: true
description: 'Global TV quota limit setting'
example: 5
globalTvQuotaDays:
type: number
nullable: true
description: 'Global TV quota days setting'
example: 14
watchlistSyncMovies:
type: boolean
nullable: true
description: 'Enable watchlist sync for movies'
example: true
watchlistSyncTv:
type: boolean
nullable: true
description: 'Enable watchlist sync for TV'
example: false
streamingRegion:
type: string
MainSettings:
type: object
properties:
@@ -260,51 +191,9 @@ components:
csrfProtection:
type: boolean
example: false
forceIpv4First:
type: boolean
example: false
trustProxy:
type: boolean
example: false
proxy:
type: object
properties:
enabled:
type: boolean
example: false
hostname:
type: string
example: ''
port:
type: number
example: 8080
useSsl:
type: boolean
example: false
user:
type: string
example: ''
password:
type: string
example: ''
bypassFilter:
type: string
example: ''
bypassLocalAddresses:
type: boolean
example: true
dnsCache:
type: object
properties:
enabled:
type: boolean
example: false
forceMinTtl:
type: number
example: 0
forceMaxTtl:
type: number
example: -1
example: true
PlexLibrary:
type: object
properties:
@@ -519,20 +408,6 @@ components:
serverID:
type: string
readOnly: true
MetadataSettings:
type: object
properties:
settings:
type: object
properties:
tv:
type: string
enum: [tvdb, tmdb]
example: 'tvdb'
anime:
type: string
enum: [tvdb, tmdb]
example: 'tvdb'
TautulliSettings:
type: object
properties:
@@ -1451,9 +1326,6 @@ components:
type: string
jsonPayload:
type: string
supportVariables:
type: boolean
example: false
TelegramSettings:
type: object
properties:
@@ -1553,6 +1425,22 @@ components:
type: boolean
token:
type: string
LunaSeaSettings:
type: object
properties:
enabled:
type: boolean
example: false
types:
type: number
example: 2
options:
type: object
properties:
webhookUrl:
type: string
profileName:
type: string
NotificationEmailSettings:
type: object
properties:
@@ -2585,67 +2473,6 @@ paths:
type: string
thumb:
type: string
/settings/metadatas:
get:
summary: Get Metadata settings
description: Retrieves current Metadata settings.
tags:
- settings
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/MetadataSettings'
put:
summary: Update Metadata settings
description: Updates Metadata settings with the provided values.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/MetadataSettings'
responses:
'200':
description: 'Values were successfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/MetadataSettings'
/settings/metadatas/test:
post:
summary: Test Provider configuration
description: Tests if the TVDB configuration is valid. Returns a list of available languages on success.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
tmdb:
type: boolean
example: true
tvdb:
type: boolean
example: true
responses:
'200':
description: Succesfully connected to TVDB
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: 'Successfully connected to TVDB'
/settings/tautulli:
get:
summary: Get Tautulli settings
@@ -3087,68 +2914,6 @@ paths:
imageCount:
type: number
example: 123
dnsCache:
type: object
properties:
stats:
type: object
properties:
size:
type: number
example: 1
maxSize:
type: number
example: 500
hits:
type: number
example: 19
misses:
type: number
example: 1
failures:
type: number
example: 0
ipv4Fallbacks:
type: number
example: 0
hitRate:
type: number
example: 0.95
entries:
type: array
additionalProperties:
type: object
properties:
addresses:
type: object
properties:
ipv4:
type: number
example: 1
ipv6:
type: number
example: 1
activeAddress:
type: string
example: 127.0.0.1
family:
type: number
example: 4
age:
type: number
example: 10
ttl:
type: number
example: 10
networkErrors:
type: number
example: 0
hits:
type: number
example: 1
misses:
type: number
example: 1
apiCaches:
type: array
items:
@@ -3188,21 +2953,6 @@ paths:
responses:
'204':
description: 'Flushed cache'
/settings/cache/dns/{dnsEntry}/flush:
post:
summary: Flush a specific DNS cache entry
description: Flushes a specific DNS cache entry
tags:
- settings
parameters:
- in: path
name: dnsEntry
required: true
schema:
type: string
responses:
'204':
description: 'Flushed dns cache'
/settings/logs:
get:
summary: Returns logs
@@ -3349,6 +3099,52 @@ paths:
responses:
'204':
description: Test notification attempted
/settings/notifications/lunasea:
get:
summary: Get LunaSea notification settings
description: Returns current LunaSea notification settings in a JSON object.
tags:
- settings
responses:
'200':
description: Returned LunaSea settings
content:
application/json:
schema:
$ref: '#/components/schemas/LunaSeaSettings'
post:
summary: Update LunaSea notification settings
description: Updates LunaSea notification settings with the provided values.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LunaSeaSettings'
responses:
'200':
description: 'Values were sucessfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/LunaSeaSettings'
/settings/notifications/lunasea/test:
post:
summary: Test LunaSea settings
description: Sends a test notification to the LunaSea agent.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LunaSeaSettings'
responses:
'204':
description: Test notification attempted
/settings/notifications/pushbullet:
get:
summary: Get Pushbullet notification settings
@@ -4311,7 +4107,7 @@ paths:
type: string
userAgent:
type: string
/user/{userId}/pushSubscription/{endpoint}:
/user/{userId}/pushSubscription/{key}:
get:
summary: Get web push notification settings for a user
description: |
@@ -4325,7 +4121,7 @@ paths:
schema:
type: number
- in: path
name: endpoint
name: key
required: true
schema:
type: string
@@ -4357,7 +4153,7 @@ paths:
schema:
type: number
- in: path
name: endpoint
name: key
required: true
schema:
type: string
@@ -4735,7 +4531,11 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/UserSettings'
type: object
properties:
username:
type: string
example: 'Mr User'
post:
summary: Update general settings for a user
description: Updates and returns general settings for a specific user. Requires `MANAGE_USERS` permission if editing other users.
@@ -4752,14 +4552,22 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/UserSettings'
type: object
properties:
username:
type: string
nullable: true
responses:
'200':
description: Updated user general settings returned
content:
application/json:
schema:
$ref: '#/components/schemas/UserSettings'
type: object
properties:
username:
type: string
example: 'Mr User'
/user/{userId}/settings/password:
get:
summary: Get password page informatiom
@@ -5198,12 +5006,6 @@ paths:
schema:
type: string
example: 1,2
- in: query
name: excludeKeywords
schema:
type: string
example: 3,4
description: Comma-separated list of keyword IDs to exclude from results
- in: query
name: sortBy
schema:
@@ -5524,12 +5326,6 @@ paths:
schema:
type: string
example: 1,2
- in: query
name: excludeKeywords
schema:
type: string
example: 3,4
description: Comma-separated list of keyword IDs to exclude from results
- in: query
name: sortBy
schema:
@@ -6562,7 +6358,7 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/TvDetails'
/tv/{tvId}/season/{seasonNumber}:
/tv/{tvId}/season/{seasonId}:
get:
summary: Get season details and episode list
description: Returns season details with a list of episodes in a JSON object.
@@ -6576,11 +6372,11 @@ paths:
type: number
example: 76479
- in: path
name: seasonNumber
name: seasonId
required: true
schema:
type: number
example: 123456
example: 1
- in: query
name: language
schema:
@@ -6865,16 +6661,9 @@ paths:
example: '1'
schema:
type: string
- in: query
name: is4k
description: Whether to remove from 4K service instance (true) or regular service instance (false)
required: false
example: false
schema:
type: boolean
responses:
'204':
description: Successfully removed media item
description: Succesfully removed media item
/media/{mediaId}/{status}:
post:
summary: Update media status
@@ -7541,22 +7330,11 @@ paths:
example: 1
responses:
'200':
description: Keyword returned (null if not found)
description: Keyword returned
content:
application/json:
schema:
nullable: true
$ref: '#/components/schemas/Keyword'
'500':
description: Internal server error
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: 'Unable to retrieve keyword data.'
/watchproviders/regions:
get:
summary: Get watch provider regions

View File

@@ -43,10 +43,10 @@
"@supercharge/request-ip": "1.2.0",
"@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.30",
"@types/ua-parser-js": "^0.7.36",
"@types/wink-jaro-distance": "^2.0.2",
"@types/ua-parser-js": "^0.7.36",
"ace-builds": "1.15.2",
"axios": "1.10.0",
"axios": "1.3.4",
"axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0",
"bowser": "2.11.0",
@@ -57,7 +57,6 @@
"cronstrue": "2.23.0",
"date-fns": "2.29.3",
"dayjs": "1.11.7",
"dns-caching": "^0.2.7",
"email-templates": "12.0.1",
"email-validator": "2.0.4",
"express": "4.21.2",
@@ -66,8 +65,6 @@
"express-session": "1.17.3",
"formik": "^2.4.6",
"gravatar-url": "3.1.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"lodash": "4.17.21",
"mime": "3",
"next": "^14.2.25",
@@ -104,8 +101,8 @@
"swr": "2.2.5",
"tailwind-merge": "^2.6.0",
"typeorm": "0.3.12",
"ua-parser-js": "^1.0.35",
"undici": "^7.3.0",
"ua-parser-js": "^1.0.35",
"web-push": "3.5.0",
"wink-jaro-distance": "^2.0.0",
"winston": "3.8.2",
@@ -116,10 +113,11 @@
"zod": "3.24.2"
},
"devDependencies": {
"@codedependant/semantic-release-docker": "^5.1.0",
"@commitlint/cli": "17.4.4",
"@commitlint/config-conventional": "17.4.4",
"@semantic-release/changelog": "6.0.3",
"@semantic-release/changelog": "6.0.2",
"@semantic-release/commit-analyzer": "9.0.2",
"@semantic-release/exec": "6.0.3",
"@semantic-release/git": "10.0.1",
"@tailwindcss/aspect-ratio": "0.4.2",
"@tailwindcss/forms": "0.5.10",
@@ -170,7 +168,8 @@
"prettier": "2.8.4",
"prettier-plugin-organize-imports": "3.2.2",
"prettier-plugin-tailwindcss": "0.2.3",
"semantic-release": "24.2.7",
"semantic-release": "19.0.5",
"semantic-release-docker-buildx": "1.0.1",
"tailwindcss": "3.2.7",
"ts-node": "10.9.1",
"tsc-alias": "1.8.2",
@@ -225,49 +224,7 @@
"message": "chore(release): ${nextRelease.version}"
}
],
[
"@codedependant/semantic-release-docker",
{
"dockerArgs": {
"COMMIT_TAG": "$GIT_SHA"
},
"dockerLogin": false,
"dockerProject": "fallenbagel",
"dockerImage": "jellyseerr",
"dockerTags": [
"latest",
"{{major}}",
"{{major}}.{{minor}}",
"{{major}}.{{minor}}.{{patch}}"
],
"dockerPlatform": [
"linux/amd64",
"linux/arm64"
]
}
],
[
"@codedependant/semantic-release-docker",
{
"dockerArgs": {
"COMMIT_TAG": "$GIT_SHA"
},
"dockerLogin": false,
"dockerRegistry": "ghcr.io",
"dockerProject": "fallenbagel",
"dockerImage": "jellyseerr",
"dockerTags": [
"latest",
"{{major}}",
"{{major}}.{{minor}}",
"{{major}}.{{minor}}.{{patch}}"
],
"dockerPlatform": [
"linux/amd64",
"linux/arm64"
]
}
],
"semantic-release-docker-buildx",
[
"@semantic-release/github",
{
@@ -280,7 +237,20 @@
],
"npmPublish": false,
"publish": [
"@codedependant/semantic-release-docker",
{
"path": "semantic-release-docker-buildx",
"buildArgs": {
"COMMIT_TAG": "$GIT_SHA"
},
"imageNames": [
"fallenbagel/jellyseerr",
"ghcr.io/fallenbagel/jellyseerr"
],
"platforms": [
"linux/amd64",
"linux/arm64"
]
},
"@semantic-release/github"
]
}

2617
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
import { requestInterceptorFunction } from '@server/utils/customProxyAgent';
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import axios from 'axios';
import rateLimit from 'axios-rate-limit';
@@ -10,7 +9,7 @@ const DEFAULT_TTL = 300;
// 10 seconds default rolling buffer (in ms)
const DEFAULT_ROLLING_BUFFER = 10000;
export interface ExternalAPIOptions {
interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
rateLimit?: {
@@ -38,7 +37,6 @@ class ExternalAPI {
...options.headers,
},
});
this.axios.interceptors.request.use(requestInterceptorFunction);
if (options.rateLimit) {
this.axios = rateLimit(this.axios, {

View File

@@ -130,7 +130,9 @@ class JellyfinAPI extends ExternalAPI {
const safeDeviceId =
deviceId && deviceId.length > 0
? deviceId
: Buffer.from('BOT_jellyseerr').toString('base64');
: Buffer.from(`BOT_jellyseerr_fallback_${Date.now()}`).toString(
'base64'
);
let authHeaderVal: string;
if (authToken) {

View File

@@ -1,39 +0,0 @@
import type { TvShowProvider } from '@server/api/provider';
import TheMovieDb from '@server/api/themoviedb';
import Tvdb from '@server/api/tvdb';
import { getSettings, MetadataProviderType } from '@server/lib/settings';
import logger from '@server/logger';
export const getMetadataProvider = async (
mediaType: 'movie' | 'tv' | 'anime'
): Promise<TvShowProvider> => {
try {
const settings = await getSettings();
if (mediaType == 'movie') {
return new TheMovieDb();
}
if (
mediaType == 'tv' &&
settings.metadataSettings.tv == MetadataProviderType.TVDB
) {
return await Tvdb.getInstance();
}
if (
mediaType == 'anime' &&
settings.metadataSettings.anime == MetadataProviderType.TVDB
) {
return await Tvdb.getInstance();
}
return new TheMovieDb();
} catch (e) {
logger.error('Failed to get metadata provider', {
label: 'Metadata',
message: e.message,
});
return new TheMovieDb();
}
};

View File

@@ -113,7 +113,7 @@ interface MetadataResponse {
ratingKey: string;
type: 'movie' | 'show';
title: string;
Guid?: {
Guid: {
id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`;
}[];
}[];
@@ -291,7 +291,7 @@ class PlexTvAPI extends ExternalAPI {
headers: {
'If-None-Match': cachedWatchlist?.etag,
},
baseURL: 'https://discover.provider.plex.tv',
baseURL: 'https://metadata.provider.plex.tv',
validateStatus: (status) => status < 400, // Allow HTTP 304 to return without error
}
);
@@ -312,32 +312,19 @@ class PlexTvAPI extends ExternalAPI {
const watchlistDetails = await Promise.all(
(cachedWatchlist?.response.MediaContainer.Metadata ?? []).map(
async (watchlistItem) => {
let detailedResponse: MetadataResponse;
try {
detailedResponse = await this.getRolling<MetadataResponse>(
`/library/metadata/${watchlistItem.ratingKey}`,
{
baseURL: 'https://discover.provider.plex.tv',
}
);
} catch (e) {
if (e.response?.status === 404) {
logger.warn(
`Item with ratingKey ${watchlistItem.ratingKey} not found, it may have been removed from the server.`,
{ label: 'Plex.TV Metadata API' }
);
return null;
} else {
throw e;
const detailedResponse = await this.getRolling<MetadataResponse>(
`/library/metadata/${watchlistItem.ratingKey}`,
{
baseURL: 'https://metadata.provider.plex.tv',
}
}
);
const metadata = detailedResponse.MediaContainer.Metadata[0];
const tmdbString = metadata.Guid?.find((guid) =>
const tmdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tmdb')
);
const tvdbString = metadata.Guid?.find((guid) =>
const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb')
);
@@ -356,9 +343,7 @@ class PlexTvAPI extends ExternalAPI {
)
);
const filteredList = watchlistDetails.filter(
(detail) => detail?.tmdbId
) as PlexWatchlistItem[];
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
return {
offset,

View File

@@ -1,30 +0,0 @@
import type {
TmdbSeasonWithEpisodes,
TmdbTvDetails,
} from '@server/api/themoviedb/interfaces';
export interface TvShowProvider {
getTvShow({
tvId,
language,
}: {
tvId: number;
language?: string;
}): Promise<TmdbTvDetails>;
getTvSeason({
tvId,
seasonNumber,
language,
}: {
tvId: number;
seasonNumber: number;
language?: string;
}): Promise<TmdbSeasonWithEpisodes>;
getShowByTvdbId({
tvdbId,
language,
}: {
tvdbId: number;
language?: string;
}): Promise<TmdbTvDetails>;
}

View File

@@ -145,7 +145,6 @@ export interface IMDBRating {
title: string;
url: string;
criticsScore: number;
criticsScoreCount: number;
}
/**
@@ -188,7 +187,6 @@ class IMDBRadarrProxy extends ExternalAPI {
title: data[0].Title,
url: `https://www.imdb.com/title/${data[0].ImdbId}`,
criticsScore: data[0].MovieRatings.Imdb.Value,
criticsScoreCount: data[0].MovieRatings.Imdb.Count,
};
} catch (e) {
throw new Error(

View File

@@ -1,7 +1,6 @@
import type { User } from '@server/entity/User';
import type { TautulliSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { requestInterceptorFunction } from '@server/utils/customProxyAgent';
import type { AxiosInstance } from 'axios';
import axios from 'axios';
import { uniqWith } from 'lodash';
@@ -124,7 +123,6 @@ class TautulliAPI {
}${settings.urlBase ?? ''}`,
params: { apikey: settings.apiKey },
});
this.axios.interceptors.request.use(requestInterceptorFunction);
}
public async getInfo(): Promise<TautulliInfo> {

View File

@@ -1,5 +1,4 @@
import ExternalAPI from '@server/api/externalapi';
import type { TvShowProvider } from '@server/api/provider';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import { sortBy } from 'lodash';
@@ -86,7 +85,6 @@ interface DiscoverMovieOptions {
genre?: string;
studio?: string;
keywords?: string;
excludeKeywords?: string;
sortBy?: SortOptions;
watchRegion?: string;
watchProviders?: string;
@@ -112,7 +110,6 @@ interface DiscoverTvOptions {
genre?: string;
network?: number;
keywords?: string;
excludeKeywords?: string;
sortBy?: SortOptions;
watchRegion?: string;
watchProviders?: string;
@@ -123,7 +120,7 @@ interface DiscoverTvOptions {
certificationCountry?: string;
}
class TheMovieDb extends ExternalAPI implements TvShowProvider {
class TheMovieDb extends ExternalAPI {
private locale: string;
private discoverRegion?: string;
private originalLanguage?: string;
@@ -344,13 +341,6 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
}
);
data.episodes = data.episodes.map((episode) => {
if (episode.still_path) {
episode.still_path = `https://image.tmdb.org/t/p/original/${episode.still_path}`;
}
return episode;
});
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
@@ -497,7 +487,6 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
genre,
studio,
keywords,
excludeKeywords,
withRuntimeGte,
withRuntimeLte,
voteAverageGte,
@@ -548,7 +537,6 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
with_genres: genre,
with_companies: studio,
with_keywords: keywords,
without_keywords: excludeKeywords,
'with_runtime.gte': withRuntimeGte,
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
@@ -581,7 +569,6 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
genre,
network,
keywords,
excludeKeywords,
withRuntimeGte,
withRuntimeLte,
voteAverageGte,
@@ -633,7 +620,6 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
with_genres: genre,
with_networks: network,
with_keywords: keywords,
without_keywords: excludeKeywords,
'with_runtime.gte': withRuntimeGte,
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
@@ -1068,7 +1054,7 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
keywordId,
}: {
keywordId: number;
}): Promise<TmdbKeyword | null> {
}): Promise<TmdbKeyword> {
try {
const data = await this.get<TmdbKeyword>(
`/keyword/${keywordId}`,
@@ -1078,9 +1064,6 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
if (e.response?.status === 404) {
return null;
}
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
}
}

View File

@@ -220,7 +220,7 @@ export interface TmdbTvEpisodeResult {
show_id: number;
still_path: string;
vote_average: number;
vote_count: number;
vote_cuont: number;
}
export interface TmdbTvSeasonResult {

View File

@@ -1,563 +0,0 @@
import ExternalAPI from '@server/api/externalapi';
import type { TvShowProvider } from '@server/api/provider';
import TheMovieDb from '@server/api/themoviedb';
import type {
TmdbSeasonWithEpisodes,
TmdbTvDetails,
TmdbTvEpisodeResult,
TmdbTvSeasonResult,
} from '@server/api/themoviedb/interfaces';
import {
convertTmdbLanguageToTvdbWithFallback,
type TvdbBaseResponse,
type TvdbEpisode,
type TvdbLoginResponse,
type TvdbSeasonDetails,
type TvdbTvDetails,
} from '@server/api/tvdb/interfaces';
import cacheManager, { type AvailableCacheIds } from '@server/lib/cache';
import logger from '@server/logger';
interface TvdbConfig {
baseUrl: string;
maxRequestsPerSecond: number;
maxRequests: number;
cachePrefix: AvailableCacheIds;
}
const DEFAULT_CONFIG: TvdbConfig = {
baseUrl: 'https://api4.thetvdb.com/v4',
maxRequestsPerSecond: 50,
maxRequests: 20,
cachePrefix: 'tvdb' as const,
};
const enum TvdbIdStatus {
INVALID = -1,
}
type TvdbId = number;
type ValidTvdbId = Exclude<TvdbId, TvdbIdStatus.INVALID>;
class Tvdb extends ExternalAPI implements TvShowProvider {
static instance: Tvdb;
private readonly tmdb: TheMovieDb;
private static readonly DEFAULT_CACHE_TTL = 43200;
private static readonly DEFAULT_LANGUAGE = 'eng';
private token: string;
private pin?: string;
constructor(pin?: string) {
const finalConfig = { ...DEFAULT_CONFIG };
super(
finalConfig.baseUrl,
{},
{
nodeCache: cacheManager.getCache(finalConfig.cachePrefix).data,
rateLimit: {
maxRequests: finalConfig.maxRequests,
maxRPS: finalConfig.maxRequestsPerSecond,
},
}
);
this.pin = pin;
this.tmdb = new TheMovieDb();
}
public static async getInstance(): Promise<Tvdb> {
if (!this.instance) {
this.instance = new Tvdb();
await this.instance.login();
}
return this.instance;
}
private async refreshToken(): Promise<void> {
try {
if (!this.token) {
await this.login();
return;
}
const base64Url = this.token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(Buffer.from(base64, 'base64').toString());
if (!payload.exp) {
await this.login();
}
const now = Math.floor(Date.now() / 1000);
const diff = payload.exp - now;
// refresh token 1 week before expiration
if (diff < 604800) {
await this.login();
}
} catch (error) {
this.handleError('Failed to refresh token', error);
}
}
public async test(): Promise<void> {
try {
await this.login();
} catch (error) {
this.handleError('Login failed', error);
throw error;
}
}
async login(): Promise<TvdbLoginResponse> {
let body: { apiKey: string; pin?: string } = {
apiKey: 'd00d9ecb-a9d0-4860-958a-74b14a041405',
};
if (this.pin) {
body = {
...body,
pin: this.pin,
};
}
const response = await this.post<TvdbBaseResponse<TvdbLoginResponse>>(
'/login',
{
...body,
}
);
this.token = response.data.token;
return response.data;
}
public async getShowByTvdbId({
tvdbId,
language,
}: {
tvdbId: number;
language?: string;
}): Promise<TmdbTvDetails> {
try {
const tmdbTvShow = await this.tmdb.getShowByTvdbId({
tvdbId: tvdbId,
language,
});
try {
await this.refreshToken();
const validTvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
if (this.isValidTvdbId(validTvdbId)) {
return this.enrichTmdbShowWithTvdbData(tmdbTvShow, validTvdbId);
}
return tmdbTvShow;
} catch (error) {
return tmdbTvShow;
}
} catch (error) {
this.handleError('Failed to fetch TV show details', error);
throw error;
}
}
public async getTvShow({
tvId,
language,
}: {
tvId: number;
language?: string;
}): Promise<TmdbTvDetails> {
try {
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
try {
await this.refreshToken();
const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
if (this.isValidTvdbId(tvdbId)) {
return await this.enrichTmdbShowWithTvdbData(tmdbTvShow, tvdbId);
}
return tmdbTvShow;
} catch (error) {
this.handleError('Failed to fetch TV show details', error);
return tmdbTvShow;
}
} catch (error) {
this.handleError('Failed to fetch TV show details', error);
return this.tmdb.getTvShow({ tvId, language });
}
}
public async getTvSeason({
tvId,
seasonNumber,
language = Tvdb.DEFAULT_LANGUAGE,
}: {
tvId: number;
seasonNumber: number;
language?: string;
}): Promise<TmdbSeasonWithEpisodes> {
try {
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
try {
await this.refreshToken();
const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
if (!this.isValidTvdbId(tvdbId)) {
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
}
return await this.getTvdbSeasonData(
tvdbId,
seasonNumber,
tvId,
language
);
} catch (error) {
this.handleError('Failed to fetch TV season details', error);
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
}
} catch (error) {
logger.error(
`[TVDB] Failed to fetch TV season details: ${error.message}`
);
throw error;
}
}
private async enrichTmdbShowWithTvdbData(
tmdbTvShow: TmdbTvDetails,
tvdbId: ValidTvdbId
): Promise<TmdbTvDetails> {
try {
await this.refreshToken();
const tvdbData = await this.fetchTvdbShowData(tvdbId);
const seasons = this.processSeasons(tvdbData);
if (!seasons.length) {
return tmdbTvShow;
}
return { ...tmdbTvShow, seasons };
} catch (error) {
logger.error(
`Failed to enrich TMDB show with TVDB data: ${error.message} token: ${this.token}`
);
return tmdbTvShow;
}
}
private async fetchTvdbShowData(tvdbId: number): Promise<TvdbTvDetails> {
const resp = await this.get<TvdbBaseResponse<TvdbTvDetails>>(
`/series/${tvdbId}/extended?meta=episodes&short=true`,
{
headers: {
Authorization: `Bearer ${this.token}`,
},
},
Tvdb.DEFAULT_CACHE_TTL
);
return resp.data;
}
private processSeasons(tvdbData: TvdbTvDetails): TmdbTvSeasonResult[] {
if (!tvdbData || !tvdbData.seasons || !tvdbData.episodes) {
return [];
}
const seasons = tvdbData.seasons
.filter((season) => season.type && season.type.type === 'official')
.sort((a, b) => a.number - b.number)
.map((season) => this.createSeasonData(season, tvdbData))
.filter(
(season) => season && season.season_number >= 0
) as TmdbTvSeasonResult[];
return seasons;
}
private createSeasonData(
season: TvdbSeasonDetails,
tvdbData: TvdbTvDetails
): TmdbTvSeasonResult {
const seasonNumber = season.number ?? -1;
if (seasonNumber < 0) {
return {
id: 0,
episode_count: 0,
name: '',
overview: '',
season_number: -1,
poster_path: '',
air_date: '',
};
}
const episodeCount = tvdbData.episodes.filter(
(episode) => episode.seasonNumber === season.number
).length;
return {
id: tvdbData.id,
episode_count: episodeCount,
name: `${season.number}`,
overview: '',
season_number: season.number,
poster_path: '',
air_date: '',
};
}
private async getTvdbSeasonData(
tvdbId: number,
seasonNumber: number,
tvId: number,
language: string = Tvdb.DEFAULT_LANGUAGE
): Promise<TmdbSeasonWithEpisodes> {
const tvdbData = await this.fetchTvdbShowData(tvdbId);
if (!tvdbData) {
logger.error(`Failed to fetch TVDB data for ID: ${tvdbId}`);
return this.createEmptySeasonResponse(tvId);
}
// get season id
const season = tvdbData.seasons.find(
(season) =>
season.number === seasonNumber &&
season.type.type &&
season.type.type === 'official'
);
if (!season) {
logger.error(
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
);
return this.createEmptySeasonResponse(tvId);
}
const wantedTranslation = convertTmdbLanguageToTvdbWithFallback(
language,
Tvdb.DEFAULT_LANGUAGE
);
// check if translation is available for the season
const availableTranslation = season.nameTranslations.filter(
(translation) =>
translation === wantedTranslation ||
translation === Tvdb.DEFAULT_LANGUAGE
);
if (!availableTranslation) {
return this.getSeasonWithOriginalLanguage(
tvdbId,
tvId,
seasonNumber,
season
);
}
return this.getSeasonWithTranslation(
tvdbId,
tvId,
seasonNumber,
season,
wantedTranslation
);
}
private async getSeasonWithTranslation(
tvdbId: number,
tvId: number,
seasonNumber: number,
season: TvdbSeasonDetails,
language: string
): Promise<TmdbSeasonWithEpisodes> {
if (!season) {
logger.error(
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
);
return this.createEmptySeasonResponse(tvId);
}
const allEpisodes = [] as TvdbEpisode[];
let page = 0;
// Limit to max 50 pages to avoid infinite loops.
// 50 pages with 500 items per page = 25_000 episodes in a series which should be more than enough
const maxPages = 50;
while (page < maxPages) {
const resp = await this.get<TvdbBaseResponse<TvdbSeasonDetails>>(
`/series/${tvdbId}/episodes/default/${language}`,
{
headers: {
Authorization: `Bearer ${this.token}`,
},
params: {
page: page,
},
}
);
if (!resp?.data?.episodes) {
logger.warn(
`No episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}`
);
break;
}
const { episodes } = resp.data;
if (!episodes) {
logger.debug(
`No more episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}`
);
break;
}
allEpisodes.push(...episodes);
const hasNextPage = resp.links?.next && episodes.length > 0;
if (!hasNextPage) {
break;
}
page++;
}
if (page >= maxPages) {
logger.warn(
`Reached max pages (${maxPages}) for TVDB ID: ${tvdbId} on season ${seasonNumber} with language ${language}. There might be more episodes available.`
);
}
const episodes = this.processEpisodes(
{ ...season, episodes: allEpisodes },
seasonNumber,
tvId
);
return {
episodes,
external_ids: { tvdb_id: tvdbId },
name: '',
overview: '',
id: season.id,
air_date: season.firstAired,
season_number: episodes.length,
};
}
private async getSeasonWithOriginalLanguage(
tvdbId: number,
tvId: number,
seasonNumber: number,
season: TvdbSeasonDetails
): Promise<TmdbSeasonWithEpisodes> {
if (!season) {
logger.error(
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
);
return this.createEmptySeasonResponse(tvId);
}
const resp = await this.get<TvdbBaseResponse<TvdbSeasonDetails>>(
`/seasons/${season.id}/extended`,
{
headers: {
Authorization: `Bearer ${this.token}`,
},
}
);
const seasons = resp.data;
const episodes = this.processEpisodes(seasons, seasonNumber, tvId);
return {
episodes,
external_ids: { tvdb_id: tvdbId },
name: '',
overview: '',
id: seasons.id,
air_date: seasons.firstAired,
season_number: episodes.length,
};
}
private processEpisodes(
tvdbSeason: TvdbSeasonDetails,
seasonNumber: number,
tvId: number
): TmdbTvEpisodeResult[] {
if (!tvdbSeason || !tvdbSeason.episodes) {
logger.error('No episodes found in TVDB season data');
return [];
}
return tvdbSeason.episodes
.filter((episode) => episode.seasonNumber === seasonNumber)
.map((episode, index) => this.createEpisodeData(episode, index, tvId));
}
private createEpisodeData(
episode: TvdbEpisode,
index: number,
tvId: number
): TmdbTvEpisodeResult {
return {
id: episode.id,
air_date: episode.aired,
episode_number: episode.number,
name: episode.name || `Episode ${index + 1}`,
overview: episode.overview || '',
season_number: episode.seasonNumber,
production_code: '',
show_id: tvId,
still_path:
episode.image && !episode.image.startsWith('https://')
? 'https://artworks.thetvdb.com' + episode.image
: '',
vote_average: 1,
vote_count: 1,
};
}
private createEmptySeasonResponse(tvId: number): TmdbSeasonWithEpisodes {
return {
episodes: [],
external_ids: { tvdb_id: tvId },
name: '',
overview: '',
id: 0,
air_date: '',
season_number: 0,
};
}
private getTvdbIdFromTmdb(tmdbTvShow: TmdbTvDetails): TvdbId {
return tmdbTvShow?.external_ids?.tvdb_id ?? TvdbIdStatus.INVALID;
}
private isValidTvdbId(tvdbId: TvdbId): tvdbId is ValidTvdbId {
return tvdbId !== TvdbIdStatus.INVALID;
}
private handleError(context: string, error: Error): void {
throw new Error(`[TVDB] ${context}: ${error.message}`);
}
}
export default Tvdb;

View File

@@ -1,216 +0,0 @@
import { type AvailableLocale } from '@server/types/languages';
export interface TvdbBaseResponse<T> {
data: T;
errors: string;
links?: TvdbPagination;
}
export interface TvdbPagination {
prev?: string;
self: string;
next?: string;
totalItems: number;
pageSize: number;
}
export interface TvdbLoginResponse {
token: string;
}
interface TvDetailsAliases {
language: string;
name: string;
}
interface TvDetailsStatus {
id: number;
name: string;
recordType: string;
keepUpdated: boolean;
}
export interface TvdbTvDetails {
id: number;
name: string;
slug: string;
image: string;
nameTranslations: string[];
overwiewTranslations: string[];
aliases: TvDetailsAliases[];
firstAired: Date;
lastAired: Date;
nextAired: Date | string;
score: number;
status: TvDetailsStatus;
originalCountry: string;
originalLanguage: string;
defaultSeasonType: string;
isOrderRandomized: boolean;
lastUpdated: Date;
averageRuntime: number;
seasons: TvdbSeasonDetails[];
episodes: TvdbEpisode[];
}
interface TvdbCompanyType {
companyTypeId: number;
companyTypeName: string;
}
interface TvdbParentCompany {
id?: number;
name?: string;
relation?: {
id?: number;
typeName?: string;
};
}
interface TvdbCompany {
id: number;
name: string;
slug: string;
nameTranslations?: string[];
overviewTranslations?: string[];
aliases?: string[];
country: string;
primaryCompanyType: number;
activeDate: string;
inactiveDate?: string;
companyType: TvdbCompanyType;
parentCompany: TvdbParentCompany;
tagOptions?: string[];
}
interface TvdbType {
id: number;
name: string;
type: string;
alternateName?: string;
}
interface TvdbArtwork {
id: number;
image: string;
thumbnail: string;
language: string;
type: number;
score: number;
width: number;
height: number;
includesText: boolean;
}
export interface TvdbEpisode {
id: number;
seriesId: number;
name: string;
aired: string;
runtime: number;
nameTranslations: string[];
overview?: string;
overviewTranslations: string[];
image: string;
imageType: number;
isMovie: number;
seasons?: string[];
number: number;
absoluteNumber: number;
seasonNumber: number;
lastUpdated: string;
finaleType?: string;
year: string;
}
export interface TvdbSeasonDetails {
id: number;
seriesId: number;
type: TvdbType;
number: number;
nameTranslations: string[];
overviewTranslations: string[];
image: string;
imageType: number;
companies: {
studio: TvdbCompany[];
network: TvdbCompany[];
production: TvdbCompany[];
distributor: TvdbCompany[];
special_effects: TvdbCompany[];
};
lastUpdated: string;
year: string;
episodes: TvdbEpisode[];
trailers: string[];
artwork: TvdbArtwork[];
tagOptions?: string[];
firstAired: string;
}
export interface TvdbEpisodeTranslation {
name: string;
overview: string;
language: string;
}
const TMDB_TO_TVDB_MAPPING: Record<string, string> & {
[key in AvailableLocale]: string;
} = {
ar: 'ara', // Arabic
bg: 'bul', // Bulgarian
ca: 'cat', // Catalan
cs: 'ces', // Czech
da: 'dan', // Danish
de: 'deu', // German
el: 'ell', // Greek
en: 'eng', // English
es: 'spa', // Spanish
fi: 'fin', // Finnish
fr: 'fra', // French
he: 'heb', // Hebrew
hi: 'hin', // Hindi
hr: 'hrv', // Croatian
hu: 'hun', // Hungarian
it: 'ita', // Italian
ja: 'jpn', // Japanese
ko: 'kor', // Korean
lt: 'lit', // Lithuanian
nl: 'nld', // Dutch
pl: 'pol', // Polish
ro: 'ron', // Romanian
ru: 'rus', // Russian
sq: 'sqi', // Albanian
sr: 'srp', // Serbian
sv: 'swe', // Swedish
tr: 'tur', // Turkish
uk: 'ukr', // Ukrainian
'es-MX': 'spa', // Spanish (Latin America) -> Spanish
'nb-NO': 'nor', // Norwegian Bokmål -> Norwegian
'pt-BR': 'pt', // Portuguese (Brazil) -> Portuguese - Brazil (from TVDB data)
'pt-PT': 'por', // Portuguese (Portugal) -> Portuguese - Portugal (from TVDB data)
'zh-CN': 'zho', // Chinese (Simplified) -> Chinese - China
'zh-TW': 'zhtw', // Chinese (Traditional) -> Chinese - Taiwan
};
export function convertTMDBToTVDB(tmdbCode: string): string | null {
const normalizedCode = tmdbCode.toLowerCase();
return (
TMDB_TO_TVDB_MAPPING[tmdbCode] ||
TMDB_TO_TVDB_MAPPING[normalizedCode] ||
null
);
}
export function convertTmdbLanguageToTvdbWithFallback(
tmdbCode: string,
fallback: string
): string {
// First try exact match
const tvdbCode = convertTMDBToTVDB(tmdbCode);
if (tvdbCode) return tvdbCode;
return tvdbCode || fallback || 'eng'; // Default to English if no match found
}

View File

@@ -9,6 +9,7 @@ import notificationManager from '@server/lib/notifications';
import DiscordAgent from '@server/lib/notifications/agents/discord';
import EmailAgent from '@server/lib/notifications/agents/email';
import GotifyAgent from '@server/lib/notifications/agents/gotify';
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
import NtfyAgent from '@server/lib/notifications/agents/ntfy';
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
import PushoverAgent from '@server/lib/notifications/agents/pushover';
@@ -25,7 +26,6 @@ import imageproxy from '@server/routes/imageproxy';
import { appDataPermissions } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion';
import createCustomProxyAgent from '@server/utils/customProxyAgent';
import { initializeDnsCache } from '@server/utils/dnsCache';
import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip';
import axios from 'axios';
@@ -43,6 +43,9 @@ import path from 'path';
import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs';
axios.defaults.httpAgent = new http.Agent({ family: 4 });
axios.defaults.httpsAgent = new https.Agent({ family: 4 });
const API_SPEC_PATH = path.join(__dirname, '../jellyseerr-api.yml');
logger.info(`Starting Jellyseerr version ${getAppVersion()}`);
@@ -76,19 +79,6 @@ app
const settings = await getSettings().load();
restartFlag.initializeSettings(settings);
if (settings.network.forceIpv4First) {
axios.defaults.httpAgent = new http.Agent({ family: 4 });
axios.defaults.httpsAgent = new https.Agent({ family: 4 });
}
// Add DNS caching
if (settings.network.dnsCache?.enabled) {
initializeDnsCache({
forceMinTtl: settings.network.dnsCache.forceMinTtl,
forceMaxTtl: settings.network.dnsCache.forceMaxTtl,
});
}
// Register HTTP proxy
if (settings.network.proxy.enabled) {
await createCustomProxyAgent(settings.network.proxy);
@@ -121,6 +111,7 @@ app
new EmailAgent(),
new GotifyAgent(),
new NtfyAgent(),
new LunaSeaAgent(),
new PushbulletAgent(),
new PushoverAgent(),
new SlackAgent(),

View File

@@ -1,4 +1,3 @@
import type { DnsEntries, DnsStats } from 'dns-caching';
import type { PaginatedResponse } from './common';
export type LogMessage = {
@@ -65,10 +64,6 @@ export interface CacheItem {
export interface CacheResponse {
apiCaches: CacheItem[];
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
dnsCache: {
stats: DnsStats | undefined;
entries: DnsEntries | undefined;
};
}
export interface StatusResponse {

View File

@@ -72,7 +72,6 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
const blacklistedTagsArr = blacklistedTags.split(',');
const pageLimit = settings.main.blacklistedTagsLimit;
const invalidKeywords = new Set<string>();
if (blacklistedTags.length === 0) {
return;
@@ -88,19 +87,6 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
// Iterate for each tag
for (const tag of blacklistedTagsArr) {
const keywordDetails = await tmdb.getKeywordDetails({
keywordId: Number(tag),
});
if (keywordDetails === null) {
logger.warn('Skipping invalid keyword in blacklisted tags', {
label: 'Blacklisted Tags Processor',
keywordId: tag,
});
invalidKeywords.add(tag);
continue;
}
let queryMax = pageLimit * SortOptionsIterable.length;
let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag
@@ -116,51 +102,24 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
throw new AbortTransaction();
}
try {
const response = await getDiscover({
page,
sortBy,
keywords: tag,
});
const response = await getDiscover({
page,
sortBy,
keywords: tag,
});
await this.processResults(response, tag, type, em);
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
await this.processResults(response, tag, type, em);
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
this.progress++;
if (page === 1 && response.total_pages <= queryMax) {
// We will finish the tag with less queries than expected, move progress accordingly
this.progress += queryMax - response.total_pages;
fixedSortMode = true;
queryMax = response.total_pages;
}
} catch (error) {
logger.error('Error processing keyword in blacklisted tags', {
label: 'Blacklisted Tags Processor',
keywordId: tag,
errorMessage: error.message,
});
this.progress++;
if (page === 1 && response.total_pages <= queryMax) {
// We will finish the tag with less queries than expected, move progress accordingly
this.progress += queryMax - response.total_pages;
fixedSortMode = true;
queryMax = response.total_pages;
}
}
}
}
if (invalidKeywords.size > 0) {
const currentTags = blacklistedTagsArr.filter(
(tag) => !invalidKeywords.has(tag)
);
const cleanedTags = currentTags.join(',');
if (cleanedTags !== blacklistedTags) {
settings.main.blacklistedTags = cleanedTags;
await settings.save();
logger.info('Cleaned up invalid keywords from settings', {
label: 'Blacklisted Tags Processor',
removedKeywords: Array.from(invalidKeywords),
newBlacklistedTags: cleanedTags,
});
}
}
}
private async processResults(

View File

@@ -9,8 +9,7 @@ export type AvailableCacheIds =
| 'github'
| 'plexguid'
| 'plextv'
| 'plexwatchlist'
| 'tvdb';
| 'plexwatchlist';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
@@ -71,10 +70,6 @@ class CacheManager {
checkPeriod: 60,
}),
plexwatchlist: new Cache('plexwatchlist', 'Plex Watchlist'),
tvdb: new Cache('tvdb', 'The TVDB API', {
stdTtl: 21600,
checkPeriod: 60 * 30,
}),
};
public getCache(id: AvailableCacheIds): Cache {

View File

@@ -1,5 +1,4 @@
import logger from '@server/logger';
import { requestInterceptorFunction } from '@server/utils/customProxyAgent';
import axios from 'axios';
import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
import { createHash } from 'crypto';
@@ -151,7 +150,6 @@ class ImageProxy {
baseURL: baseUrl,
headers: options.headers,
});
this.axios.interceptors.request.use(requestInterceptorFunction);
if (options.rateLimitOptions) {
this.axios = rateLimit(this.axios, options.rateLimitOptions);

View File

@@ -109,9 +109,7 @@ class DiscordAgent
type: Notification,
payload: NotificationPayload
): DiscordRichEmbed {
const settings = getSettings();
const { applicationUrl } = settings.main;
const { embedPoster } = settings.notifications.agents.discord;
const { applicationUrl } = getSettings().main;
const appUrl =
applicationUrl || `http://localhost:${process.env.port || 5055}`;
@@ -225,11 +223,9 @@ class DiscordAgent
}
: undefined,
fields,
thumbnail: embedPoster
? {
url: payload.image,
}
: undefined,
thumbnail: {
url: payload.image,
},
};
}

View File

@@ -48,9 +48,7 @@ class EmailAgent
recipientEmail: string,
recipientName?: string
): EmailOptions | undefined {
const settings = getSettings();
const { applicationUrl, applicationTitle } = settings.main;
const { embedPoster } = settings.notifications.agents.email;
const { applicationUrl, applicationTitle } = getSettings().main;
if (type === Notification.TEST_NOTIFICATION) {
return {
@@ -131,7 +129,7 @@ class EmailAgent
body,
mediaName: payload.subject,
mediaExtra: payload.extra ?? [],
imageUrl: embedPoster ? payload.image : undefined,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.request.requestedBy.displayName,
actionUrl: applicationUrl
@@ -178,7 +176,7 @@ class EmailAgent
issueComment: payload.comment?.message,
mediaName: payload.subject,
extra: payload.extra ?? [],
imageUrl: embedPoster ? payload.image : undefined,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
actionUrl: applicationUrl
? `${applicationUrl}/issues/${payload.issue.id}`

View File

@@ -35,7 +35,7 @@ class GotifyAgent
settings.enabled &&
settings.options.url &&
settings.options.token &&
settings.options.priority !== undefined
settings.options.priority
) {
return true;
}

View File

@@ -0,0 +1,133 @@
import { IssueStatus, IssueType } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
import type { NotificationAgentLunaSea } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
class LunaSeaAgent
extends BaseAgent<NotificationAgentLunaSea>
implements NotificationAgent
{
protected getSettings(): NotificationAgentLunaSea {
if (this.settings) {
return this.settings;
}
const settings = getSettings();
return settings.notifications.agents.lunasea;
}
private buildPayload(type: Notification, payload: NotificationPayload) {
return {
notification_type: Notification[type],
event: payload.event,
subject: payload.subject,
message: payload.message,
image: payload.image ?? null,
email: payload.notifyUser?.email,
username: payload.notifyUser?.displayName,
avatar: payload.notifyUser?.avatar,
media: payload.media
? {
media_type: payload.media.mediaType,
tmdbId: payload.media.tmdbId,
tvdbId: payload.media.tvdbId,
status: MediaStatus[payload.media.status],
status4k: MediaStatus[payload.media.status4k],
}
: null,
extra: payload.extra ?? [],
request: payload.request
? {
request_id: payload.request.id,
requestedBy_email: payload.request.requestedBy.email,
requestedBy_username: payload.request.requestedBy.displayName,
requestedBy_avatar: payload.request.requestedBy.avatar,
}
: null,
issue: payload.issue
? {
issue_id: payload.issue.id,
issue_type: IssueType[payload.issue.issueType],
issue_status: IssueStatus[payload.issue.status],
createdBy_email: payload.issue.createdBy.email,
createdBy_username: payload.issue.createdBy.displayName,
createdBy_avatar: payload.issue.createdBy.avatar,
}
: null,
comment: payload.comment
? {
comment_message: payload.comment.message,
commentedBy_email: payload.comment.user.email,
commentedBy_username: payload.comment.user.displayName,
commentedBy_avatar: payload.comment.user.avatar,
}
: null,
};
}
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.webhookUrl) {
return true;
}
return false;
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const settings = this.getSettings();
if (
!payload.notifySystem ||
!hasNotificationType(type, settings.types ?? 0)
) {
return true;
}
logger.debug('Sending LunaSea notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
try {
await axios.post(
settings.options.webhookUrl,
this.buildPayload(type, payload),
settings.options.profileName
? {
headers: {
Authorization: `Basic ${Buffer.from(
`${settings.options.profileName}:`
).toString('base64')}`,
},
}
: undefined
);
return true;
} catch (e) {
logger.error('Error sending LunaSea notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e?.response?.data,
});
return false;
}
}
}
export default LunaSeaAgent;

View File

@@ -22,9 +22,7 @@ class NtfyAgent
}
private buildPayload(type: Notification, payload: NotificationPayload) {
const settings = getSettings();
const { applicationUrl } = settings.main;
const { embedPoster } = settings.notifications.agents.ntfy;
const { applicationUrl } = getSettings().main;
const topic = this.getSettings().options.topic;
const priority = 3;
@@ -74,7 +72,7 @@ class NtfyAgent
message += `\n\n**${extra.name}**\n${extra.value}`;
}
const attach = embedPoster ? payload.image : undefined;
const attach = payload.image;
let click;
if (applicationUrl && payload.media) {

View File

@@ -78,9 +78,7 @@ class PushoverAgent
type: Notification,
payload: NotificationPayload
): Promise<Partial<PushoverPayload>> {
const settings = getSettings();
const { applicationUrl, applicationTitle } = settings.main;
const { embedPoster } = settings.notifications.agents.pushover;
const { applicationUrl, applicationTitle } = getSettings().main;
const title = payload.event ?? payload.subject;
let message = payload.event ? `<b>${payload.subject}</b>` : '';
@@ -157,7 +155,7 @@ class PushoverAgent
let attachment_base64;
let attachment_type;
if (embedPoster && payload.image) {
if (payload.image) {
const imagePayload = await this.getImagePayload(payload.image);
if (imagePayload.attachment_base64 && imagePayload.attachment_type) {
attachment_base64 = imagePayload.attachment_base64;

View File

@@ -63,9 +63,7 @@ class SlackAgent
type: Notification,
payload: NotificationPayload
): SlackBlockEmbed {
const settings = getSettings();
const { applicationUrl, applicationTitle } = settings.main;
const { embedPoster } = settings.notifications.agents.slack;
const { applicationUrl, applicationTitle } = getSettings().main;
const fields: EmbedField[] = [];
@@ -161,14 +159,13 @@ class SlackAgent
type: 'mrkdwn',
text: payload.message,
},
accessory:
embedPoster && payload.image
? {
type: 'image',
image_url: payload.image,
alt_text: payload.subject,
}
: undefined,
accessory: payload.image
? {
type: 'image',
image_url: payload.image,
alt_text: payload.subject,
}
: undefined,
});
}

View File

@@ -65,9 +65,7 @@ class TelegramAgent
type: Notification,
payload: NotificationPayload
): Partial<TelegramMessagePayload | TelegramPhotoPayload> {
const settings = getSettings();
const { applicationUrl, applicationTitle } = settings.main;
const { embedPoster } = settings.notifications.agents.telegram;
const { applicationUrl, applicationTitle } = getSettings().main;
/* eslint-disable no-useless-escape */
let message = `\*${this.escapeText(
@@ -144,7 +142,7 @@ class TelegramAgent
}
/* eslint-enable */
return embedPoster && payload.image
return payload.image
? {
photo: payload.image,
caption: message,
@@ -162,7 +160,7 @@ class TelegramAgent
): Promise<boolean> {
const settings = this.getSettings();
const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${
settings.embedPoster && payload.image ? 'sendPhoto' : 'sendMessage'
payload.image ? 'sendPhoto' : 'sendMessage'
}`;
const notificationPayload = this.getNotificationPayload(type, payload);

View File

@@ -177,27 +177,9 @@ class WebhookAgent
subject: payload.subject,
});
let webhookUrl = settings.options.webhookUrl;
if (settings.options.supportVariables) {
Object.keys(KeyMap).forEach((keymapKey) => {
const keymapValue = KeyMap[keymapKey as keyof typeof KeyMap];
const variableValue =
type === Notification.TEST_NOTIFICATION
? 'test'
: typeof keymapValue === 'function'
? keymapValue(payload, type)
: get(payload, keymapValue) || 'test';
webhookUrl = webhookUrl.replace(
new RegExp(`{{${keymapKey}}}`, 'g'),
encodeURIComponent(variableValue)
);
});
}
try {
await axios.post(
webhookUrl,
settings.options.webhookUrl,
this.buildPayload(type, payload),
settings.options.authHeader
? {

View File

@@ -42,8 +42,6 @@ class WebPushAgent
type: Notification,
payload: NotificationPayload
): PushNotificationPayload {
const { embedPoster } = getSettings().notifications.agents.webpush;
const mediaType = payload.media
? payload.media.mediaType === MediaType.MOVIE
? 'movie'
@@ -130,7 +128,7 @@ class WebPushAgent
notificationType: Notification[type],
subject: payload.subject,
message,
image: embedPoster ? payload.image : undefined,
image: payload.image,
requestId: payload.request?.id,
actionUrl,
actionUrlTitle,

View File

@@ -1,12 +1,7 @@
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
import JellyfinAPI from '@server/api/jellyfin';
import { getMetadataProvider } from '@server/api/metadata';
import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type {
TmdbKeyword,
TmdbTvDetails,
} from '@server/api/themoviedb/interfaces';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
@@ -48,7 +43,6 @@ class JellyfinScanner {
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
this.tmdb = new TheMovieDb();
this.isRecentOnly = isRecentOnly ?? false;
}
@@ -198,42 +192,6 @@ class JellyfinScanner {
}
}
private async getTvShow({
tmdbId,
tvdbId,
}: {
tmdbId?: number;
tvdbId?: number;
}): Promise<TmdbTvDetails> {
let tvShow;
if (tmdbId) {
tvShow = await this.tmdb.getTvShow({
tvId: Number(tmdbId),
});
} else if (tvdbId) {
tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: Number(tvdbId),
});
} else {
throw new Error('No ID provided');
}
const metadataProvider = tvShow.keywords.results.some(
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
)
? await getMetadataProvider('anime')
: await getMetadataProvider('tv');
if (!(metadataProvider instanceof TheMovieDb)) {
tvShow = await metadataProvider.getTvShow({
tvId: Number(tmdbId),
});
}
return tvShow;
}
private async processShow(jellyfinitem: JellyfinLibraryItem) {
const mediaRepository = getRepository(Media);
@@ -254,8 +212,8 @@ class JellyfinScanner {
if (metadata.ProviderIds.Tmdb) {
try {
tvShow = await this.getTvShow({
tmdbId: Number(metadata.ProviderIds.Tmdb),
tvShow = await this.tmdb.getTvShow({
tvId: Number(metadata.ProviderIds.Tmdb),
});
} catch {
this.log('Unable to find TMDb ID for this title.', 'debug', {
@@ -265,7 +223,7 @@ class JellyfinScanner {
}
if (!tvShow && metadata.ProviderIds.Tvdb) {
try {
tvShow = await this.getTvShow({
tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: Number(metadata.ProviderIds.Tvdb),
});
} catch {

View File

@@ -1,13 +1,7 @@
import animeList from '@server/api/animelist';
import { getMetadataProvider } from '@server/api/metadata';
import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi';
import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type {
TmdbKeyword,
TmdbTvDetails,
} from '@server/api/themoviedb/interfaces';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import cacheManager from '@server/lib/cache';
@@ -255,42 +249,6 @@ class PlexScanner
});
}
private async getTvShow({
tmdbId,
tvdbId,
}: {
tmdbId?: number;
tvdbId?: number;
}): Promise<TmdbTvDetails> {
let tvShow;
if (tmdbId) {
tvShow = await this.tmdb.getTvShow({
tvId: Number(tmdbId),
});
} else if (tvdbId) {
tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: Number(tvdbId),
});
} else {
throw new Error('No ID provided');
}
const metadataProvider = tvShow.keywords.results.some(
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
)
? await getMetadataProvider('anime')
: await getMetadataProvider('tv');
if (!(metadataProvider instanceof TheMovieDb)) {
tvShow = await metadataProvider.getTvShow({
tvId: Number(tmdbId),
});
}
return tvShow;
}
private async processPlexShow(plexitem: PlexLibraryItem) {
const ratingKey =
plexitem.grandparentRatingKey ??
@@ -315,9 +273,7 @@ class PlexScanner
await this.processHamaSpecials(metadata, mediaIds.tvdbId);
}
const tvShow = await this.getTvShow({
tmdbId: mediaIds.tmdbId,
});
const tvShow = await this.tmdb.getTvShow({ tvId: mediaIds.tmdbId });
const seasons = tvShow.seasons;
const processableSeasons: ProcessableSeason[] = [];

View File

@@ -100,16 +100,6 @@ interface Quota {
quotaDays?: number;
}
export enum MetadataProviderType {
TMDB = 'tmdb',
TVDB = 'tvdb',
}
export interface MetadataSettings {
tv: MetadataProviderType;
anime: MetadataProviderType;
}
export interface ProxySettings {
enabled: boolean;
hostname: string;
@@ -148,29 +138,10 @@ export interface MainSettings {
youtubeUrl: string;
}
export interface ProxySettings {
enabled: boolean;
hostname: string;
port: number;
useSsl: boolean;
user: string;
password: string;
bypassFilter: string;
bypassLocalAddresses: boolean;
}
export interface DnsCacheSettings {
enabled: boolean;
forceMinTtl?: number;
forceMaxTtl?: number;
}
export interface NetworkSettings {
csrfProtection: boolean;
forceIpv4First: boolean;
trustProxy: boolean;
proxy: ProxySettings;
dnsCache: DnsCacheSettings;
}
interface PublicSettings {
@@ -207,7 +178,6 @@ interface FullPublicSettings extends PublicSettings {
export interface NotificationAgentConfig {
enabled: boolean;
embedPoster: boolean;
types?: number;
options: Record<string, unknown>;
}
@@ -245,6 +215,13 @@ export interface NotificationAgentEmail extends NotificationAgentConfig {
};
}
export interface NotificationAgentLunaSea extends NotificationAgentConfig {
options: {
webhookUrl: string;
profileName?: string;
};
}
export interface NotificationAgentTelegram extends NotificationAgentConfig {
options: {
botUsername?: string;
@@ -275,7 +252,6 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
webhookUrl: string;
jsonPayload: string;
authHeader?: string;
supportVariables?: boolean;
};
}
@@ -317,6 +293,7 @@ interface NotificationAgents {
email: NotificationAgentEmail;
gotify: NotificationAgentGotify;
ntfy: NotificationAgentNtfy;
lunasea: NotificationAgentLunaSea;
pushbullet: NotificationAgentPushbullet;
pushover: NotificationAgentPushover;
slack: NotificationAgentSlack;
@@ -362,7 +339,6 @@ export interface AllSettings {
notifications: NotificationSettings;
jobs: Record<JobId, JobSettings>;
network: NetworkSettings;
metadataSettings: MetadataSettings;
}
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
@@ -423,10 +399,6 @@ class Settings {
apiKey: '',
},
tautulli: {},
metadataSettings: {
tv: MetadataProviderType.TMDB,
anime: MetadataProviderType.TMDB,
},
radarr: [],
sonarr: [],
public: {
@@ -436,7 +408,6 @@ class Settings {
agents: {
email: {
enabled: false,
embedPoster: true,
options: {
userEmailRequired: false,
emailFrom: '',
@@ -451,7 +422,6 @@ class Settings {
},
discord: {
enabled: false,
embedPoster: true,
types: 0,
options: {
webhookUrl: '',
@@ -459,9 +429,15 @@ class Settings {
enableMentions: true,
},
},
lunasea: {
enabled: false,
types: 0,
options: {
webhookUrl: '',
},
},
slack: {
enabled: false,
embedPoster: true,
types: 0,
options: {
webhookUrl: '',
@@ -469,7 +445,6 @@ class Settings {
},
telegram: {
enabled: false,
embedPoster: true,
types: 0,
options: {
botAPI: '',
@@ -480,7 +455,6 @@ class Settings {
},
pushbullet: {
enabled: false,
embedPoster: false,
types: 0,
options: {
accessToken: '',
@@ -488,7 +462,6 @@ class Settings {
},
pushover: {
enabled: false,
embedPoster: true,
types: 0,
options: {
accessToken: '',
@@ -498,7 +471,6 @@ class Settings {
},
webhook: {
enabled: false,
embedPoster: true,
types: 0,
options: {
webhookUrl: '',
@@ -508,12 +480,10 @@ class Settings {
},
webpush: {
enabled: false,
embedPoster: true,
options: {},
},
gotify: {
enabled: false,
embedPoster: false,
types: 0,
options: {
url: '',
@@ -523,7 +493,6 @@ class Settings {
},
ntfy: {
enabled: false,
embedPoster: true,
types: 0,
options: {
url: '',
@@ -575,7 +544,6 @@ class Settings {
},
network: {
csrfProtection: false,
forceIpv4First: false,
trustProxy: false,
proxy: {
enabled: false,
@@ -587,11 +555,6 @@ class Settings {
bypassFilter: '',
bypassLocalAddresses: true,
},
dnsCache: {
enabled: false,
forceMinTtl: 0,
forceMaxTtl: -1,
},
},
};
if (initialSettings) {
@@ -631,14 +594,6 @@ class Settings {
this.data.tautulli = data;
}
get metadataSettings(): MetadataSettings {
return this.data.metadataSettings;
}
set metadataSettings(data: MetadataSettings) {
this.data.metadataSettings = data;
}
get radarr(): RadarrSettings[] {
return this.data.radarr;
}

View File

@@ -1,14 +0,0 @@
import type { AllSettings } from '@server/lib/settings';
const removeLunaSeaSetting = (settings: any): AllSettings => {
if (
settings.notifications &&
settings.notifications.agents &&
settings.notifications.agents.lunasea
) {
delete settings.notifications.agents.lunasea;
}
return settings;
};
export default removeLunaSeaSetting;

View File

@@ -124,7 +124,7 @@ const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
seasonNumber: episode.season_number,
showId: episode.show_id,
voteAverage: episode.vote_average,
voteCount: episode.vote_count,
voteCount: episode.vote_cuont,
stillPath: episode.still_path,
});

View File

@@ -277,14 +277,11 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
select: { id: true, jellyfinDeviceId: true },
});
let deviceId = 'BOT_jellyseerr';
if (user && user.id === 1) {
// Admin is always BOT_jellyseerr
deviceId = 'BOT_jellyseerr';
} else if (user && user.jellyfinDeviceId) {
deviceId = user.jellyfinDeviceId;
} else if (body.username) {
deviceId = Buffer.from(`BOT_jellyseerr_${body.username}`).toString(
let deviceId = '';
if (user) {
deviceId = user.jellyfinDeviceId ?? '';
} else {
deviceId = Buffer.from(`BOT_jellyseerr_${body.username ?? ''}`).toString(
'base64'
);
}

View File

@@ -23,7 +23,7 @@ async function initAvatarImageProxy() {
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' },
});
const deviceId = admin?.jellyfinDeviceId || 'BOT_jellyseerr';
const deviceId = admin?.jellyfinDeviceId;
const authToken = getSettings().jellyfin.apiKey;
_avatarImageProxy = new ImageProxy('avatar', '', {
headers: {

View File

@@ -61,7 +61,6 @@ const QueryFilterOptions = z.object({
studio: z.coerce.string().optional(),
genre: z.coerce.string().optional(),
keywords: z.coerce.string().optional(),
excludeKeywords: z.coerce.string().optional(),
language: z.coerce.string().optional(),
withRuntimeGte: z.coerce.string().optional(),
withRuntimeLte: z.coerce.string().optional(),
@@ -91,7 +90,6 @@ discoverRoutes.get('/movies', async (req, res, next) => {
try {
const query = ApiQuerySchema.parse(req.query);
const keywords = query.keywords;
const excludeKeywords = query.excludeKeywords;
const data = await tmdb.getDiscoverMovies({
page: Number(query.page),
@@ -107,7 +105,6 @@ discoverRoutes.get('/movies', async (req, res, next) => {
? new Date(query.primaryReleaseDateGte).toISOString().split('T')[0]
: undefined,
keywords,
excludeKeywords,
withRuntimeGte: query.withRuntimeGte,
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
@@ -131,15 +128,11 @@ discoverRoutes.get('/movies', async (req, res, next) => {
if (keywords) {
const splitKeywords = keywords.split(',');
const keywordResults = await Promise.all(
keywordData = await Promise.all(
splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
})
);
keywordData = keywordResults.filter(
(keyword): keyword is TmdbKeyword => keyword !== null
);
}
return res.status(200).json({
@@ -384,7 +377,6 @@ discoverRoutes.get('/tv', async (req, res, next) => {
try {
const query = ApiQuerySchema.parse(req.query);
const keywords = query.keywords;
const excludeKeywords = query.excludeKeywords;
const data = await tmdb.getDiscoverTv({
page: Number(query.page),
sortBy: query.sortBy as SortOptions,
@@ -399,7 +391,6 @@ discoverRoutes.get('/tv', async (req, res, next) => {
: undefined,
originalLanguage: query.language,
keywords,
excludeKeywords,
withRuntimeGte: query.withRuntimeGte,
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
@@ -424,15 +415,11 @@ discoverRoutes.get('/tv', async (req, res, next) => {
if (keywords) {
const splitKeywords = keywords.split(',');
const keywordResults = await Promise.all(
keywordData = await Promise.all(
splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
})
);
keywordData = keywordResults.filter(
(keyword): keyword is TmdbKeyword => keyword !== null
);
}
return res.status(200).json({

View File

@@ -4,40 +4,27 @@ import { Router } from 'express';
const router = Router();
// Delay the initialization of ImageProxy instances until the proxy (if any) is properly configured
let _tmdbImageProxy: ImageProxy;
function initTmdbImageProxy() {
if (!_tmdbImageProxy) {
_tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});
}
return _tmdbImageProxy;
}
let _tvdbImageProxy: ImageProxy;
function initTvdbImageProxy() {
if (!_tvdbImageProxy) {
_tvdbImageProxy = new ImageProxy('tvdb', 'https://artworks.thetvdb.com', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});
}
return _tvdbImageProxy;
}
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});
const tvdbImageProxy = new ImageProxy('tvdb', 'https://artworks.thetvdb.com', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});
router.get('/:type/*', async (req, res) => {
const imagePath = req.path.replace(/^\/\w+/, '');
try {
let imageData;
if (req.params.type === 'tmdb') {
imageData = await initTmdbImageProxy().getImage(imagePath);
imageData = await tmdbImageProxy.getImage(imagePath);
} else if (req.params.type === 'tvdb') {
imageData = await initTvdbImageProxy().getImage(imagePath);
imageData = await tvdbImageProxy.getImage(imagePath);
} else {
logger.error('Unsupported image type', {
imagePath,

View File

@@ -54,7 +54,6 @@ issueRoutes.get<Record<string, string>, IssueResultsResponse>(
.leftJoinAndSelect('issue.createdBy', 'createdBy')
.leftJoinAndSelect('issue.media', 'media')
.leftJoinAndSelect('issue.modifiedBy', 'modifiedBy')
.leftJoinAndSelect('issue.comments', 'comments')
.where('issue.status IN (:...issueStatus)', {
issueStatus: statusFilter,
});

View File

@@ -197,10 +197,8 @@ mediaRoutes.delete(
const media = await mediaRepository.findOneOrFail({
where: { id: Number(req.params.id) },
});
const is4k = req.query.is4k === 'true';
const is4k = media.serviceUrl4k !== undefined;
const isMovie = media.mediaType === MediaType.MOVIE;
let serviceSettings;
if (isMovie) {
serviceSettings = settings.radarr.find(
@@ -227,7 +225,6 @@ mediaRoutes.delete(
);
}
}
if (!serviceSettings) {
logger.warn(
`There is no default ${
@@ -242,7 +239,6 @@ mediaRoutes.delete(
);
return;
}
let service;
if (isMovie) {
service = new RadarrAPI({

View File

@@ -28,9 +28,7 @@ import discoverSettingRoutes from '@server/routes/settings/discover';
import { ApiError } from '@server/types/error';
import { appDataPath } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion';
import { dnsCache } from '@server/utils/dnsCache';
import { getHostname } from '@server/utils/getHostname';
import type { DnsEntries, DnsStats } from 'dns-caching';
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import fs from 'fs';
@@ -39,7 +37,6 @@ import { rescheduleJob } from 'node-schedule';
import path from 'path';
import semver from 'semver';
import { URL } from 'url';
import metadataRoutes from './metadata';
import notificationRoutes from './notifications';
import radarrRoutes from './radarr';
import sonarrRoutes from './sonarr';
@@ -50,7 +47,6 @@ settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes);
settingsRoutes.use('/discover', discoverSettingRoutes);
settingsRoutes.use('/metadatas', metadataRoutes);
const filteredMainSettings = (
user: User,
@@ -759,19 +755,12 @@ settingsRoutes.get('/cache', async (_req, res) => {
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
const avatarImageCache = await ImageProxy.getImageStats('avatar');
const stats: DnsStats | undefined = dnsCache?.getStats();
const entries: DnsEntries | undefined = dnsCache?.getCacheEntries();
return res.status(200).json({
apiCaches,
imageCache: {
tmdb: tmdbImageCache,
avatar: avatarImageCache,
},
dnsCache: {
stats,
entries,
},
});
});
@@ -789,20 +778,6 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
}
);
settingsRoutes.post<{ dnsEntry: string }>(
'/cache/dns/:dnsEntry/flush',
(req, res, next) => {
const dnsEntry = req.params.dnsEntry;
if (dnsCache) {
dnsCache.clear(dnsEntry);
return res.status(204).send();
}
next({ status: 404, message: 'Cache not found.' });
}
);
settingsRoutes.post(
'/initialize',
isAuthenticated(Permission.ADMIN),

View File

@@ -1,153 +0,0 @@
import TheMovieDb from '@server/api/themoviedb';
import Tvdb from '@server/api/tvdb';
import {
getSettings,
MetadataProviderType,
type MetadataSettings,
} from '@server/lib/settings';
import logger from '@server/logger';
import { Router } from 'express';
function getTestResultString(testValue: number): string {
if (testValue === -1) return 'not tested';
if (testValue === 0) return 'failed';
return 'ok';
}
const metadataRoutes = Router();
metadataRoutes.get('/', (_req, res) => {
const settings = getSettings();
res.status(200).json({
tv: settings.metadataSettings.tv,
anime: settings.metadataSettings.anime,
});
});
metadataRoutes.put('/', async (req, res) => {
const settings = getSettings();
const body = req.body as MetadataSettings;
let tvdbTest = -1;
let tmdbTest = -1;
try {
if (
body.tv === MetadataProviderType.TVDB ||
body.anime === MetadataProviderType.TVDB
) {
tvdbTest = 0;
const tvdb = await Tvdb.getInstance();
await tvdb.test();
tvdbTest = 1;
}
} catch (e) {
logger.error('Failed to test metadata provider', {
label: 'Metadata',
message: e.message,
});
}
try {
if (
body.tv === MetadataProviderType.TMDB ||
body.anime === MetadataProviderType.TMDB
) {
tmdbTest = 0;
const tmdb = new TheMovieDb();
await tmdb.getTvShow({ tvId: 1054 });
tmdbTest = 1;
}
} catch (e) {
logger.error('Failed to test metadata provider', {
label: 'MetadataProvider',
message: e.message,
});
}
// If a test failed, return the test results
if (tvdbTest === 0 || tmdbTest === 0) {
return res.status(500).json({
success: false,
tests: {
tvdb: getTestResultString(tvdbTest),
tmdb: getTestResultString(tmdbTest),
},
});
}
settings.metadataSettings = {
tv: body.tv,
anime: body.anime,
};
await settings.save();
res.status(200).json({
success: true,
tv: body.tv,
anime: body.anime,
tests: {
tvdb: getTestResultString(tvdbTest),
tmdb: getTestResultString(tmdbTest),
},
});
});
metadataRoutes.post('/test', async (req, res) => {
let tvdbTest = -1;
let tmdbTest = -1;
try {
const body = req.body as { tmdb: boolean; tvdb: boolean };
try {
if (body.tmdb) {
tmdbTest = 0;
const tmdb = new TheMovieDb();
await tmdb.getTvShow({ tvId: 1054 });
tmdbTest = 1;
}
} catch (e) {
logger.error('Failed to test metadata provider', {
label: 'MetadataProvider',
message: e.message,
});
}
try {
if (body.tvdb) {
tvdbTest = 0;
const tvdb = await Tvdb.getInstance();
await tvdb.test();
tvdbTest = 1;
}
} catch (e) {
logger.error('Failed to test metadata provider', {
label: 'MetadataProvider',
message: e.message,
});
}
const success = !(tvdbTest === 0 || tmdbTest === 0);
const statusCode = success ? 200 : 500;
return res.status(statusCode).json({
success: success,
tests: {
tmdb: getTestResultString(tmdbTest),
tvdb: getTestResultString(tvdbTest),
},
});
} catch (e) {
return res.status(500).json({
success: false,
tests: {
tmdb: getTestResultString(tmdbTest),
tvdb: getTestResultString(tvdbTest),
},
error: e.message,
});
}
});
export default metadataRoutes;

View File

@@ -4,6 +4,7 @@ import type { NotificationAgent } from '@server/lib/notifications/agents/agent';
import DiscordAgent from '@server/lib/notifications/agents/discord';
import EmailAgent from '@server/lib/notifications/agents/email';
import GotifyAgent from '@server/lib/notifications/agents/gotify';
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
import NtfyAgent from '@server/lib/notifications/agents/ntfy';
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
import PushoverAgent from '@server/lib/notifications/agents/pushover';
@@ -270,7 +271,6 @@ notificationRoutes.get('/webhook', (_req, res) => {
const response: typeof webhookSettings = {
enabled: webhookSettings.enabled,
embedPoster: webhookSettings.embedPoster,
types: webhookSettings.types,
options: {
...webhookSettings.options,
@@ -279,7 +279,6 @@ notificationRoutes.get('/webhook', (_req, res) => {
'utf8'
)
),
supportVariables: webhookSettings.options.supportVariables ?? false,
},
};
@@ -293,7 +292,6 @@ notificationRoutes.post('/webhook', async (req, res, next) => {
settings.notifications.agents.webhook = {
enabled: req.body.enabled,
embedPoster: req.body.embedPoster,
types: req.body.types,
options: {
jsonPayload: Buffer.from(req.body.options.jsonPayload).toString(
@@ -301,7 +299,6 @@ notificationRoutes.post('/webhook', async (req, res, next) => {
),
webhookUrl: req.body.options.webhookUrl,
authHeader: req.body.options.authHeader,
supportVariables: req.body.options.supportVariables ?? false,
},
};
await settings.save();
@@ -325,7 +322,6 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
const testBody = {
enabled: req.body.enabled,
embedPoster: req.body.embedPoster,
types: req.body.types,
options: {
jsonPayload: Buffer.from(req.body.options.jsonPayload).toString(
@@ -333,7 +329,6 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
),
webhookUrl: req.body.options.webhookUrl,
authHeader: req.body.options.authHeader,
supportVariables: req.body.options.supportVariables ?? false,
},
};
@@ -351,6 +346,40 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
}
});
notificationRoutes.get('/lunasea', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.notifications.agents.lunasea);
});
notificationRoutes.post('/lunasea', async (req, res) => {
const settings = getSettings();
settings.notifications.agents.lunasea = req.body;
await settings.save();
res.status(200).json(settings.notifications.agents.lunasea);
});
notificationRoutes.post('/lunasea/test', async (req, res, next) => {
if (!req.user) {
return next({
status: 500,
message: 'User information is missing from the request.',
});
}
const lunaseaAgent = new LunaSeaAgent(req.body);
if (await sendTestNotification(lunaseaAgent, req.user)) {
return res.status(204).send();
} else {
return next({
status: 500,
message: 'Failed to send web push notification.',
});
}
});
notificationRoutes.get('/gotify', (_req, res) => {
const settings = getSettings();

View File

@@ -1,8 +1,5 @@
import { getMetadataProvider } from '@server/api/metadata';
import RottenTomatoes from '@server/api/rating/rottentomatoes';
import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
@@ -16,20 +13,12 @@ const tvRoutes = Router();
tvRoutes.get('/:id', async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const tmdbTv = await tmdb.getTvShow({
tvId: Number(req.params.id),
});
const metadataProvider = tmdbTv.keywords.results.some(
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
)
? await getMetadataProvider('anime')
: await getMetadataProvider('tv');
const tv = await metadataProvider.getTvShow({
const tv = await tmdb.getTvShow({
tvId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale,
});
const media = await Media.getMedia(tv.id, MediaType.TV);
const onUserWatchlist = await getRepository(Watchlist).exist({
@@ -45,9 +34,7 @@ tvRoutes.get('/:id', async (req, res, next) => {
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
if (!data.overview) {
const tvEnglish = await metadataProvider.getTvShow({
tvId: Number(req.params.id),
});
const tvEnglish = await tmdb.getTvShow({ tvId: Number(req.params.id) });
data.overview = tvEnglish.overview;
}
@@ -66,18 +53,10 @@ tvRoutes.get('/:id', async (req, res, next) => {
});
tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
try {
const tmdb = new TheMovieDb();
const tmdbTv = await tmdb.getTvShow({
tvId: Number(req.params.id),
});
const metadataProvider = tmdbTv.keywords.results.some(
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
)
? await getMetadataProvider('anime')
: await getMetadataProvider('tv');
const tmdb = new TheMovieDb();
const season = await metadataProvider.getTvSeason({
try {
const season = await tmdb.getTvSeason({
tvId: Number(req.params.id),
seasonNumber: Number(req.params.seasonNumber),
language: (req.query.language as string) ?? req.locale,

View File

@@ -240,8 +240,8 @@ router.get<{ userId: number }>(
}
);
router.get<{ userId: number; endpoint: string }>(
'/:userId/pushSubscription/:endpoint',
router.get<{ userId: number; key: string }>(
'/:userId/pushSubscription/:key',
async (req, res, next) => {
try {
const userPushSubRepository = getRepository(UserPushSubscription);
@@ -252,7 +252,7 @@ router.get<{ userId: number; endpoint: string }>(
},
where: {
user: { id: req.params.userId },
endpoint: req.params.endpoint,
p256dh: req.params.key,
},
});
@@ -263,8 +263,8 @@ router.get<{ userId: number; endpoint: string }>(
}
);
router.delete<{ userId: number; endpoint: string }>(
'/:userId/pushSubscription/:endpoint',
router.delete<{ userId: number; key: string }>(
'/:userId/pushSubscription/:key',
async (req, res, next) => {
try {
const userPushSubRepository = getRepository(UserPushSubscription);
@@ -275,7 +275,7 @@ router.delete<{ userId: number; endpoint: string }>(
},
where: {
user: { id: req.params.userId },
endpoint: req.params.endpoint,
p256dh: req.params.key,
},
});
@@ -284,7 +284,7 @@ router.delete<{ userId: number; endpoint: string }>(
} catch (e) {
logger.error('Something went wrong deleting the user push subcription', {
label: 'API',
endpoint: req.params.endpoint,
key: req.params.key,
errorMessage: e.message,
});
return next({

View File

@@ -421,9 +421,7 @@ userSettingsRoutes.post<{ username: string; password: string }>(
const hostname = getHostname();
const deviceId = Buffer.from(
req.user?.id === 1
? 'BOT_jellyseerr'
: `BOT_jellyseerr_${req.user.username ?? ''}`
`BOT_jellyseerr_${req.user.username ?? ''}`
).toString('base64');
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);

View File

@@ -33,93 +33,52 @@ import { EventSubscriber } from 'typeorm';
export class MediaRequestSubscriber
implements EntitySubscriberInterface<MediaRequest>
{
private async notifyAvailableMovie(
entity: MediaRequest,
event?: UpdateEvent<MediaRequest>
) {
// Get fresh media state using event manager
let latestMedia: Media | null = null;
if (event?.manager) {
latestMedia = await event.manager.findOne(Media, {
where: { id: entity.media.id },
});
}
if (!latestMedia) {
const mediaRepository = getRepository(Media);
latestMedia = await mediaRepository.findOne({
where: { id: entity.media.id },
});
}
// Check availability using fresh media state
private async notifyAvailableMovie(entity: MediaRequest) {
if (
!latestMedia ||
latestMedia[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE
entity.media[entity.is4k ? 'status4k' : 'status'] ===
MediaStatus.AVAILABLE
) {
return;
}
const tmdb = new TheMovieDb();
const tmdb = new TheMovieDb();
try {
const movie = await tmdb.getMovie({
movieId: entity.media.tmdbId,
});
try {
const movie = await tmdb.getMovie({
movieId: entity.media.tmdbId,
});
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
event: `${entity.is4k ? '4K ' : ''}Movie Request Now Available`,
notifyAdmin: false,
notifySystem: true,
notifyUser: entity.requestedBy,
subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`,
message: truncate(movie.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
media: latestMedia,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
request: entity,
});
} catch (e) {
logger.error('Something went wrong sending media notification(s)', {
label: 'Notifications',
errorMessage: e.message,
mediaId: entity.id,
});
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
event: `${entity.is4k ? '4K ' : ''}Movie Request Now Available`,
notifyAdmin: false,
notifySystem: true,
notifyUser: entity.requestedBy,
subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`,
message: truncate(movie.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
media: entity.media,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
request: entity,
});
} catch (e) {
logger.error('Something went wrong sending media notification(s)', {
label: 'Notifications',
errorMessage: e.message,
mediaId: entity.id,
});
}
}
}
private async notifyAvailableSeries(
entity: MediaRequest,
event?: UpdateEvent<MediaRequest>
) {
// Get fresh media state with seasons using event manager
let latestMedia: Media | null = null;
if (event?.manager) {
latestMedia = await event.manager.findOne(Media, {
where: { id: entity.media.id },
relations: { seasons: true },
});
}
if (!latestMedia) {
const mediaRepository = getRepository(Media);
latestMedia = await mediaRepository.findOne({
where: { id: entity.media.id },
relations: { seasons: true },
});
}
if (!latestMedia) {
return;
}
// Check availability using fresh media state
private async notifyAvailableSeries(entity: MediaRequest) {
// Find all seasons in the related media entity
// and see if they are available, then we can check
// if the request contains the same seasons
const requestedSeasons =
entity.seasons?.map((entitySeason) => entitySeason.seasonNumber) ?? [];
const availableSeasons = latestMedia.seasons.filter(
const availableSeasons = entity.media.seasons.filter(
(season) =>
season[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE &&
requestedSeasons.includes(season.seasonNumber)
@@ -128,46 +87,44 @@ export class MediaRequestSubscriber
availableSeasons.length > 0 &&
availableSeasons.length === requestedSeasons.length;
if (!isMediaAvailable) {
return;
}
if (isMediaAvailable) {
const tmdb = new TheMovieDb();
const tmdb = new TheMovieDb();
try {
const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
try {
const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
event: `${entity.is4k ? '4K ' : ''}Series Request Now Available`,
subject: `${tv.name}${
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
}`,
message: truncate(tv.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
notifyAdmin: false,
notifySystem: true,
notifyUser: entity.requestedBy,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
media: latestMedia,
extra: [
{
name: 'Requested Seasons',
value: entity.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
request: entity,
});
} catch (e) {
logger.error('Something went wrong sending media notification(s)', {
label: 'Notifications',
errorMessage: e.message,
mediaId: entity.id,
});
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
event: `${entity.is4k ? '4K ' : ''}Series Request Now Available`,
subject: `${tv.name}${
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
}`,
message: truncate(tv.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
notifyAdmin: false,
notifySystem: true,
notifyUser: entity.requestedBy,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
media: entity.media,
extra: [
{
name: 'Requested Seasons',
value: entity.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
request: entity,
});
} catch (e) {
logger.error('Something went wrong sending media notification(s)', {
label: 'Notifications',
errorMessage: e.message,
mediaId: entity.id,
});
}
}
}
@@ -825,10 +782,10 @@ export class MediaRequestSubscriber
if (event.entity.status === MediaRequestStatus.COMPLETED) {
if (event.entity.media.mediaType === MediaType.MOVIE) {
this.notifyAvailableMovie(event.entity as MediaRequest, event);
this.notifyAvailableMovie(event.entity as MediaRequest);
}
if (event.entity.media.mediaType === MediaType.TV) {
this.notifyAvailableSeries(event.entity as MediaRequest, event);
this.notifyAvailableSeries(event.entity as MediaRequest);
}
}
}

View File

@@ -53,11 +53,10 @@ div(style='display: block; background-color: #111827; padding: 2.5rem 0;')
b(style='color: #9ca3af; font-weight: 700;')
| #{extra.name}&nbsp;
| #{extra.value}
if imageUrl
td(rowspan='2' style='width: 7rem;')
a(style='display: block; width: 7rem; overflow: hidden; border-radius: .375rem;' href=actionUrl)
div(style='overflow: hidden; box-sizing: border-box; margin: 0px;')
img(alt='' src=imageUrl style='box-sizing: border-box; padding: 0px; border: none; margin: auto; display: block; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;')
td(rowspan='2' style='width: 7rem;')
a(style='display: block; width: 7rem; overflow: hidden; border-radius: .375rem;' href=actionUrl)
div(style='overflow: hidden; box-sizing: border-box; margin: 0px;')
img(alt='' src=imageUrl style='box-sizing: border-box; padding: 0px; border: none; margin: auto; display: block; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;')
tr
td(style='font-size: .85em; color: #9ca3af; line-height: 1em; vertical-align: bottom; margin-right: 1rem')
span

View File

@@ -1,35 +0,0 @@
export type AvailableLocale =
| 'ar'
| 'bg'
| 'ca'
| 'cs'
| 'da'
| 'de'
| 'en'
| 'el'
| 'es'
| 'es-MX'
| 'fi'
| 'fr'
| 'hr'
| 'he'
| 'hi'
| 'hu'
| 'it'
| 'ja'
| 'ko'
| 'lt'
| 'nb-NO'
| 'nl'
| 'pl'
| 'pt-BR'
| 'pt-PT'
| 'ro'
| 'ru'
| 'sq'
| 'sr'
| 'sv'
| 'tr'
| 'uk'
| 'zh-CN'
| 'zh-TW';

View File

@@ -1,15 +1,9 @@
import type { ProxySettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios, { type InternalAxiosRequestConfig } from 'axios';
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import axios from 'axios';
import type { Dispatcher } from 'undici';
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
export let requestInterceptorFunction: (
config: InternalAxiosRequestConfig
) => InternalAxiosRequestConfig;
export default async function createCustomProxyAgent(
proxySettings: ProxySettings
) {
@@ -60,35 +54,17 @@ export default async function createCustomProxyAgent(
: undefined;
try {
const proxyUrl = `${proxySettings.useSsl ? 'https' : 'http'}://${
proxySettings.hostname
}:${proxySettings.port}`;
const proxyAgent = new ProxyAgent({
uri: proxyUrl,
uri:
(proxySettings.useSsl ? 'https://' : 'http://') +
proxySettings.hostname +
':' +
proxySettings.port,
token,
keepAliveTimeout: 5000,
});
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl, {
headers: token ? { 'proxy-authorization': token } : undefined,
});
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl, {
headers: token ? { 'proxy-authorization': token } : undefined,
});
requestInterceptorFunction = (config) => {
const url = config.baseURL
? new URL(config.baseURL + (config.url || ''))
: config.url;
if (url && skipUrl(url)) {
config.httpAgent = false;
config.httpsAgent = false;
}
return config;
};
axios.interceptors.request.use(requestInterceptorFunction);
} catch (e) {
logger.error('Failed to connect to the proxy: ' + e.message, {
label: 'Proxy',

View File

@@ -1,26 +0,0 @@
import logger from '@server/logger';
import { DnsCacheManager } from 'dns-caching';
export let dnsCache: DnsCacheManager | undefined;
export function initializeDnsCache({
forceMinTtl,
forceMaxTtl,
}: {
forceMinTtl?: number;
forceMaxTtl?: number;
}) {
if (dnsCache) {
logger.warn('DNS Cache is already initialized', { label: 'DNS Cache' });
return;
}
logger.info('Initializing DNS Cache', { label: 'DNS Cache' });
dnsCache = new DnsCacheManager({
logger,
forceMinTtl: typeof forceMinTtl === 'number' ? forceMinTtl * 1000 : 0,
forceMaxTtl: typeof forceMaxTtl === 'number' ? forceMaxTtl * 1000 : -1,
});
dnsCache.initialize();
}

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 750 750" xmlns="http://www.w3.org/2000/svg"><g fill="currentColor"><path d="m554.69 180.46c-333.63 0-452.75 389.23-556.05 389.23 185.37 0 237.85-247.18 419.12-247.18l47.24-102.05z"/><path d="m749.31 375.08c0 107.48-87.14 194.61-194.62 194.61s-194.62-87.13-194.62-194.61 87.13-194.62 194.62-194.62c7.391-2e-3 14.776 0.412 22.12 1.24-78.731 10.172-136.59 78.893-133.2 158.2 3.393 79.313 66.907 142.84 146.22 146.25 79.311 3.411 148.05-54.43 158.24-133.16 0.826 7.331 1.24 14.703 1.24 22.08z"/></g></svg>

After

Width:  |  Height:  |  Size: 519 B

View File

@@ -29,10 +29,14 @@ const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => {
const keywordIds = data.blacklistedTags.slice(1, -1).split(',');
Promise.all(
keywordIds.map(async (keywordId) => {
const { data } = await axios.get<Keyword | null>(
`/api/v1/keyword/${keywordId}`
);
return data?.name || `[Invalid: ${keywordId}]`;
try {
const { data } = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return data.name;
} catch (err) {
return '';
}
})
).then((keywords) => {
setTagNamesBlacklistedFor(keywords.join(', '));

View File

@@ -5,10 +5,7 @@ import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { ArrowDownIcon } from '@heroicons/react/24/solid';
import type {
TmdbKeyword,
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
import type { TmdbKeywordSearchResponse } from '@server/api/themoviedb/interfaces';
import type { Keyword } from '@server/models/common';
import axios from 'axios';
import { useFormikContext } from 'formik';
@@ -127,19 +124,15 @@ const ControlledKeywordSelector = ({
const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => {
const { data } = await axios.get<Keyword | null>(
const { data } = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return data;
})
);
const validKeywords: TmdbKeyword[] = keywords.filter(
(keyword): keyword is TmdbKeyword => keyword !== null
);
onChange(
validKeywords.map((keyword) => ({
keywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))

View File

@@ -84,7 +84,6 @@ const SettingsTabs = ({
Select a Tab
</label>
<select
id="tabs"
onChange={(e) => {
router.push(e.target.value);
}}

View File

@@ -77,19 +77,16 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
const keywords = await Promise.all(
slider.data.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword | null>(
const keyword = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return keyword.data;
})
);
const validKeywords: Keyword[] = keywords.filter(
(keyword): keyword is Keyword => keyword !== null
);
setDefaultDataValue(
validKeywords.map((keyword) => ({
keywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))

View File

@@ -33,7 +33,6 @@ const messages = defineMessages('components.Discover.FilterSlideover', {
studio: 'Studio',
genres: 'Genres',
keywords: 'Keywords',
excludeKeywords: 'Exclude Keywords',
originalLanguage: 'Original Language',
runtimeText: '{minValue}-{maxValue} minute runtime',
ratingText: 'Ratings between {minValue} and {maxValue}',
@@ -182,19 +181,6 @@ const FilterSlideover = ({
updateQueryParams('keywords', value?.map((v) => v.value).join(','));
}}
/>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.excludeKeywords)}
</span>
<KeywordSelector
defaultValue={currentFilters.excludeKeywords}
isMulti
onChange={(value) => {
updateQueryParams(
'excludeKeywords',
value?.map((v) => v.value).join(',')
);
}}
/>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.originalLanguage)}
</span>

View File

@@ -99,7 +99,6 @@ export const QueryFilterOptions = z.object({
studio: z.string().optional(),
genre: z.string().optional(),
keywords: z.string().optional(),
excludeKeywords: z.string().optional(),
language: z.string().optional(),
withRuntimeGte: z.string().optional(),
withRuntimeLte: z.string().optional(),
@@ -162,10 +161,6 @@ export const prepareFilterValues = (
filterValues.keywords = values.keywords;
}
if (values.excludeKeywords) {
filterValues.excludeKeywords = values.excludeKeywords;
}
if (values.language) {
filterValues.language = values.language;
}

View File

@@ -1,7 +1,6 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import Tooltip from '@app/components/Common/Tooltip';
import { issueOptions } from '@app/components/IssueModal/constants';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
@@ -27,7 +26,6 @@ const messages = defineMessages('components.IssueList.IssueItem', {
opened: 'Opened',
viewissue: 'View Issue',
unknownissuetype: 'Unknown',
descriptionpreview: 'Issue Description',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
@@ -109,15 +107,8 @@ const IssueItem = ({ issue }: IssueItemProps) => {
}
}
const description = issue.comments?.[0]?.message || '';
const maxDescriptionLength = 120;
const shouldTruncate = description.length > maxDescriptionLength;
const truncatedDescription = shouldTruncate
? description.substring(0, maxDescriptionLength) + '...'
: description;
return (
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:flex-row">
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
{title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage
@@ -177,38 +168,8 @@ const IssueItem = ({ issue }: IssueItemProps) => {
>
{isMovie(title) ? title.title : title.name}
</Link>
{description && (
<div className="mt-1 max-w-full">
<div className="overflow-hidden text-sm text-gray-300">
{shouldTruncate ? (
<Tooltip
content={
<div className="max-w-sm p-3">
<div className="mb-1 text-sm font-medium text-gray-200">
Issue Description
</div>
<div className="whitespace-pre-wrap text-sm leading-relaxed text-gray-300">
{description}
</div>
</div>
}
tooltipConfig={{
placement: 'top',
offset: [0, 8],
}}
>
<span className="block cursor-help truncate transition-colors hover:text-gray-200">
{truncatedDescription}
</span>
</Tooltip>
) : (
<span className="block break-words">{description}</span>
)}
</div>
</div>
)}
{problemSeasonEpisodeLine.length > 0 && (
<div className="card-field mt-1">
<div className="card-field">
{problemSeasonEpisodeLine.map((t, k) => (
<span key={k}>{t}</span>
))}

View File

@@ -1,10 +1,10 @@
import type { AvailableLocale } from '@app/context/LanguageContext';
import { availableLanguages } from '@app/context/LanguageContext';
import useClickOutside from '@app/hooks/useClickOutside';
import useLocale from '@app/hooks/useLocale';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { LanguageIcon } from '@heroicons/react/24/solid';
import type { AvailableLocale } from '@server/types/languages';
import { useRef, useState } from 'react';
import { useIntl } from 'react-intl';

View File

@@ -1,6 +1,6 @@
import CachedImage from '@app/components/Common/CachedImage';
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
import { Permission, useUser } from '@app/hooks/useUser';
import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { Menu, Transition } from '@headlessui/react';
import {
@@ -36,7 +36,7 @@ ForwardedLink.displayName = 'ForwardedLink';
const UserDropdown = () => {
const intl = useIntl();
const { user, revalidate, hasPermission } = useUser();
const { user, revalidate } = useUser();
const logout = async () => {
const response = await axios.post('/api/v1/auth/logout');
@@ -118,14 +118,7 @@ const UserDropdown = () => {
<Menu.Item>
{({ active }) => (
<ForwardedLink
href={
hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
)
? `/users/${user?.id}/requests?filter=all`
: '/requests'
}
href={`/users/${user?.id}/requests?filter=all`}
className={`flex items-center rounded px-4 py-2 text-sm font-medium text-gray-200 transition duration-150 ease-in-out ${
active
? 'bg-gradient-to-br from-indigo-600 to-purple-600 text-white'

View File

@@ -3,11 +3,11 @@ import PullToRefresh from '@app/components/Layout/PullToRefresh';
import SearchInput from '@app/components/Layout/SearchInput';
import Sidebar from '@app/components/Layout/Sidebar';
import UserDropdown from '@app/components/Layout/UserDropdown';
import type { AvailableLocale } from '@app/context/LanguageContext';
import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid';
import type { AvailableLocale } from '@server/types/languages';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import useSWR from 'swr';

View File

@@ -118,11 +118,9 @@ const ManageSlideOver = ({
}
};
const deleteMediaFile = async (is4k = false) => {
const deleteMediaFile = async () => {
if (data.mediaInfo) {
await axios.delete(
`/api/v1/media/${data.mediaInfo.id}/file?is4k=${is4k}`
);
await axios.delete(`/api/v1/media/${data.mediaInfo.id}/file`);
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
revalidate();
onClose();
@@ -416,7 +414,7 @@ const ManageSlideOver = ({
isDefaultService() && (
<div>
<ConfirmButton
onClick={() => deleteMediaFile(false)}
onClick={() => deleteMediaFile()}
confirmText={intl.formatMessage(
globalMessages.areyousure
)}
@@ -575,7 +573,7 @@ const ManageSlideOver = ({
{isDefaultService() && (
<div>
<ConfirmButton
onClick={() => deleteMediaFile(true)}
onClick={() => deleteMediaFile()}
confirmText={intl.formatMessage(
globalMessages.areyousure
)}

View File

@@ -74,14 +74,6 @@ const MediaSlider = ({
);
}
if (settings.currentSettings.hideBlacklisted) {
titles = titles.filter(
(i) =>
(i.mediaType === 'movie' || i.mediaType === 'tv') &&
i.mediaInfo?.status !== MediaStatus.BLACKLISTED
);
}
useEffect(() => {
if (
titles.length < 24 &&

View File

@@ -1,91 +0,0 @@
import defineMessages from '@app/utils/defineMessages';
import { useIntl } from 'react-intl';
import Select, { type StylesConfig } from 'react-select';
enum MetadataProviderType {
TMDB = 'tmdb',
TVDB = 'tvdb',
}
type MetadataProviderOptionType = {
testId?: string;
value: MetadataProviderType;
label: string;
};
const messages = defineMessages('components.MetadataSelector', {
tmdbLabel: 'The Movie Database (TMDB)',
tvdbLabel: 'TheTVDB',
selectMetdataProvider: 'Select a metadata provider',
});
interface MetadataSelectorProps {
testId: string;
value: MetadataProviderType;
onChange: (value: MetadataProviderType) => void;
isDisabled?: boolean;
}
const MetadataSelector = ({
testId = 'metadata-provider-selector',
value,
onChange,
isDisabled = false,
}: MetadataSelectorProps) => {
const intl = useIntl();
const metadataProviderOptions: MetadataProviderOptionType[] = [
{
testId: 'tmdb-option',
value: MetadataProviderType.TMDB,
label: intl.formatMessage(messages.tmdbLabel),
},
{
testId: 'tvdb-option',
value: MetadataProviderType.TVDB,
label: intl.formatMessage(messages.tvdbLabel),
},
];
const customStyles: StylesConfig<MetadataProviderOptionType, false> = {
option: (base) => ({
...base,
display: 'flex',
alignItems: 'center',
}),
singleValue: (base) => ({
...base,
display: 'flex',
alignItems: 'center',
}),
};
const formatOptionLabel = (option: MetadataProviderOptionType) => (
<div className="flex items-center">
<span data-testid={option.testId}>{option.label}</span>
</div>
);
return (
<div data-testid={testId}>
<Select
options={metadataProviderOptions}
isDisabled={isDisabled}
className="react-select-container"
classNamePrefix="react-select"
value={metadataProviderOptions.find((option) => option.value === value)}
onChange={(selectedOption) => {
if (selectedOption) {
onChange(selectedOption.value);
}
}}
placeholder={intl.formatMessage(messages.selectMetdataProvider)}
styles={customStyles}
formatOptionLabel={formatOptionLabel}
/>
</div>
);
};
export { MetadataProviderType };
export default MetadataSelector;

View File

@@ -99,7 +99,7 @@ const messages = defineMessages('components.MovieDetails', {
rtcriticsscore: 'Rotten Tomatoes Tomatometer',
rtaudiencescore: 'Rotten Tomatoes Audience Score',
tmdbuserscore: 'TMDB User Score',
imdbuserscore: 'IMDB User Score votes: {formattedCount}',
imdbuserscore: 'IMDB User Score',
watchlistSuccess: '<strong>{title}</strong> added to watchlist successfully!',
watchlistDeleted:
'<strong>{title}</strong> Removed from watchlist successfully!',
@@ -812,18 +812,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
</Tooltip>
)}
{ratingData?.imdb?.criticsScore && (
<Tooltip
content={intl.formatMessage(messages.imdbuserscore, {
formattedCount: intl.formatNumber(
ratingData.imdb.criticsScoreCount,
{
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 1,
}
),
})}
>
<Tooltip content={intl.formatMessage(messages.imdbuserscore)}>
<a
href={ratingData.imdb.url}
className="media-rating"

View File

@@ -152,6 +152,7 @@ const PWAHeader = ({ applicationTitle = 'Jellyseerr' }: PWAHeaderProps) => {
href="/apple-splash-1136-640.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"

View File

@@ -1,6 +1,5 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import Tooltip from '@app/components/Common/Tooltip';
import RequestModal from '@app/components/RequestModal';
import useRequestOverride from '@app/hooks/useRequestOverride';
@@ -96,58 +95,36 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
<div className="flex items-center justify-between">
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
<div className="white mb-1 flex flex-nowrap">
<span className="flex w-40 items-center truncate md:w-auto">
<Tooltip content={intl.formatMessage(messages.requestedby)}>
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
</Tooltip>
<Tooltip content={intl.formatMessage(messages.requestedby)}>
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
</Tooltip>
<span className="w-40 truncate md:w-auto">
<Link
href={
request.requestedBy.id === user?.id
? '/profile'
: `/users/${request.requestedBy.id}`
}
className="flex items-center font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
>
<span className="avatar-sm">
<CachedImage
type="avatar"
src={request.requestedBy.avatar}
alt=""
className="avatar-sm object-cover"
width={20}
height={20}
/>
</span>
{request.requestedBy.displayName}
</Link>
</span>
</div>
{request.modifiedBy && (
<div className="flex flex-nowrap">
<span className="flex w-40 items-center truncate md:w-auto">
<Tooltip
content={intl.formatMessage(messages.lastmodifiedby)}
>
<EyeIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
</Tooltip>
<Tooltip content={intl.formatMessage(messages.lastmodifiedby)}>
<EyeIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
</Tooltip>
<span className="w-40 truncate md:w-auto">
<Link
href={
request.modifiedBy.id === user?.id
? '/profile'
: `/users/${request.modifiedBy.id}`
}
className="flex items-center font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
>
<span className="avatar-sm">
<CachedImage
type="avatar"
src={request.modifiedBy.avatar}
alt=""
className="avatar-sm object-cover"
width={20}
height={20}
/>
</span>
{request.modifiedBy.displayName}
</Link>
</span>

View File

@@ -343,9 +343,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const deleteMediaFile = async () => {
if (request.media) {
await axios.delete(
`/api/v1/media/${request.media.id}/file?is4k=${request.is4k}`
);
await axios.delete(`/api/v1/media/${request.media.id}/file`);
await axios.delete(`/api/v1/media/${request.media.id}`);
revalidateList();
}

View File

@@ -309,19 +309,16 @@ export const KeywordSelector = ({
const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword | null>(
const keyword = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return keyword.data;
})
);
const validKeywords: Keyword[] = keywords.filter(
(keyword): keyword is Keyword => keyword !== null
);
setDefaultDataValue(
validKeywords.map((keyword) => ({
keywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))

View File

@@ -15,7 +15,6 @@ import * as Yup from 'yup';
const messages = defineMessages('components.Settings.Notifications', {
agentenabled: 'Enable Agent',
embedPoster: 'Embed Poster',
botUsername: 'Bot Username',
botAvatarUrl: 'Bot Avatar URL',
webhookUrl: 'Webhook URL',
@@ -75,7 +74,6 @@ const NotificationsDiscord = () => {
<Formik
initialValues={{
enabled: data.enabled,
embedPoster: data.embedPoster,
types: data.types,
botUsername: data?.options.botUsername,
botAvatarUrl: data?.options.botAvatarUrl,
@@ -88,7 +86,6 @@ const NotificationsDiscord = () => {
try {
await axios.post('/api/v1/settings/notifications/discord', {
enabled: values.enabled,
embedPoster: values.embedPoster,
types: values.types,
options: {
botUsername: values.botUsername,
@@ -138,7 +135,6 @@ const NotificationsDiscord = () => {
);
await axios.post('/api/v1/settings/notifications/discord/test', {
enabled: true,
embedPoster: values.embedPoster,
types: values.types,
options: {
botUsername: values.botUsername,
@@ -180,14 +176,6 @@ const NotificationsDiscord = () => {
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
<div className="form-row">
<label htmlFor="embedPoster" className="checkbox-label">
{intl.formatMessage(messages.embedPoster)}
</label>
<div className="form-input-area">
<Field type="checkbox" id="embedPoster" name="embedPoster" />
</div>
</div>
<div className="form-row">
<label htmlFor="name" className="text-label">
{intl.formatMessage(messages.webhookUrl)}

View File

@@ -17,7 +17,6 @@ const messages = defineMessages('components.Settings.Notifications', {
validationSmtpHostRequired: 'You must provide a valid hostname or IP address',
validationSmtpPortRequired: 'You must provide a valid port number',
agentenabled: 'Enable Agent',
embedPoster: 'Embed Poster',
userEmailRequired: 'Require user email',
emailsender: 'Sender Address',
smtpHost: 'SMTP Host',
@@ -78,13 +77,18 @@ const NotificationsEmail = () => {
otherwise: Yup.string().nullable(),
})
.email(intl.formatMessage(messages.validationEmail)),
smtpHost: Yup.string().when('enabled', {
is: true,
then: Yup.string()
.nullable()
.required(intl.formatMessage(messages.validationSmtpHostRequired)),
otherwise: Yup.string().nullable(),
}),
smtpHost: Yup.string()
.when('enabled', {
is: true,
then: Yup.string()
.nullable()
.required(intl.formatMessage(messages.validationSmtpHostRequired)),
otherwise: Yup.string().nullable(),
})
.matches(
/^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
intl.formatMessage(messages.validationSmtpHostRequired)
),
smtpPort: Yup.number().when('enabled', {
is: true,
then: Yup.number()
@@ -123,7 +127,6 @@ const NotificationsEmail = () => {
<Formik
initialValues={{
enabled: data.enabled,
embedPoster: data.embedPoster,
userEmailRequired: data.options.userEmailRequired,
emailFrom: data.options.emailFrom,
smtpHost: data.options.smtpHost,
@@ -147,7 +150,6 @@ const NotificationsEmail = () => {
try {
await axios.post('/api/v1/settings/notifications/email', {
enabled: values.enabled,
embedPoster: values.embedPoster,
options: {
userEmailRequired: values.userEmailRequired,
emailFrom: values.emailFrom,
@@ -197,7 +199,6 @@ const NotificationsEmail = () => {
);
await axios.post('/api/v1/settings/notifications/email/test', {
enabled: true,
embedPoster: values.embedPoster,
options: {
emailFrom: values.emailFrom,
smtpHost: values.smtpHost,
@@ -245,14 +246,6 @@ const NotificationsEmail = () => {
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
<div className="form-row">
<label htmlFor="embedPoster" className="checkbox-label">
{intl.formatMessage(messages.embedPoster)}
</label>
<div className="form-input-area">
<Field type="checkbox" id="embedPoster" name="embedPoster" />
</div>
</div>
<div className="form-row">
<label htmlFor="userEmailRequired" className="checkbox-label">
{intl.formatMessage(messages.userEmailRequired)}

View File

@@ -0,0 +1,272 @@
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
const messages = defineMessages(
'components.Settings.Notifications.NotificationsLunaSea',
{
agentenabled: 'Enable Agent',
webhookUrl: 'Webhook URL',
webhookUrlTip:
'Your user- or device-based <LunaSeaLink>notification webhook URL</LunaSeaLink>',
validationWebhookUrl: 'You must provide a valid URL',
profileName: 'Profile Name',
profileNameTip:
'Only required if not using the <code>default</code> profile',
settingsSaved: 'LunaSea notification settings saved successfully!',
settingsFailed: 'LunaSea notification settings failed to save.',
toastLunaSeaTestSending: 'Sending LunaSea test notification…',
toastLunaSeaTestSuccess: 'LunaSea test notification sent!',
toastLunaSeaTestFailed: 'LunaSea test notification failed to send.',
validationTypes: 'You must select at least one notification type',
}
);
const NotificationsLunaSea = () => {
const intl = useIntl();
const { addToast, removeToast } = useToasts();
const [isTesting, setIsTesting] = useState(false);
const {
data,
error,
mutate: revalidate,
} = useSWR('/api/v1/settings/notifications/lunasea');
const NotificationsLunaSeaSchema = Yup.object().shape({
webhookUrl: Yup.string()
.when('enabled', {
is: true,
then: Yup.string()
.nullable()
.required(intl.formatMessage(messages.validationWebhookUrl)),
otherwise: Yup.string().nullable(),
})
.url(intl.formatMessage(messages.validationWebhookUrl)),
});
if (!data && !error) {
return <LoadingSpinner />;
}
return (
<Formik
initialValues={{
enabled: data.enabled,
types: data.types,
webhookUrl: data.options.webhookUrl,
profileName: data.options.profileName,
}}
validationSchema={NotificationsLunaSeaSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/lunasea', {
enabled: values.enabled,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
profileName: values.profileName,
},
});
addToast(intl.formatMessage(messages.settingsSaved), {
appearance: 'success',
autoDismiss: true,
});
} catch (e) {
addToast(intl.formatMessage(messages.settingsFailed), {
appearance: 'error',
autoDismiss: true,
});
} finally {
revalidate();
}
}}
>
{({
errors,
touched,
isSubmitting,
values,
isValid,
setFieldValue,
setFieldTouched,
}) => {
const testSettings = async () => {
setIsTesting(true);
let toastId: string | undefined;
try {
addToast(
intl.formatMessage(messages.toastLunaSeaTestSending),
{
autoDismiss: false,
appearance: 'info',
},
(id) => {
toastId = id;
}
);
await axios.post('/api/v1/settings/notifications/lunasea/test', {
enabled: true,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
profileName: values.profileName,
},
});
if (toastId) {
removeToast(toastId);
}
addToast(intl.formatMessage(messages.toastLunaSeaTestSuccess), {
autoDismiss: true,
appearance: 'success',
});
} catch (e) {
if (toastId) {
removeToast(toastId);
}
addToast(intl.formatMessage(messages.toastLunaSeaTestFailed), {
autoDismiss: true,
appearance: 'error',
});
} finally {
setIsTesting(false);
}
};
return (
<Form className="section">
<div className="form-row">
<label htmlFor="enabled" className="checkbox-label">
{intl.formatMessage(messages.agentenabled)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
<div className="form-row">
<label htmlFor="name" className="text-label">
{intl.formatMessage(messages.webhookUrl)}
<span className="label-required">*</span>
<span className="label-tip">
{intl.formatMessage(messages.webhookUrlTip, {
LunaSeaLink: (msg: React.ReactNode) => (
<a
href="https://docs.lunasea.app/lunasea/notifications/overseerr"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
),
})}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="webhookUrl"
name="webhookUrl"
type="text"
inputMode="url"
/>
</div>
{errors.webhookUrl &&
touched.webhookUrl &&
typeof errors.webhookUrl === 'string' && (
<div className="error">{errors.webhookUrl}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="profileName" className="text-label">
{intl.formatMessage(messages.profileName)}
<span className="label-tip">
{intl.formatMessage(messages.profileNameTip, {
code: (msg: React.ReactNode) => (
<code className="bg-opacity-50">{msg}</code>
),
})}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="profileName" name="profileName" type="text" />
</div>
</div>
</div>
<NotificationTypeSelector
currentTypes={values.enabled ? values.types : 0}
onUpdate={(newTypes) => {
setFieldValue('types', newTypes);
setFieldTouched('types');
if (newTypes) {
setFieldValue('enabled', true);
}
}}
error={
values.enabled && !values.types && touched.types
? intl.formatMessage(messages.validationTypes)
: undefined
}
/>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid || isTesting}
onClick={(e) => {
e.preventDefault();
testSettings();
}}
>
<BeakerIcon />
<span>
{isTesting
? intl.formatMessage(globalMessages.testing)
: intl.formatMessage(globalMessages.test)}
</span>
</Button>
</span>
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={
isSubmitting ||
!isValid ||
isTesting ||
(values.enabled && !values.types)
}
>
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
</span>
</Button>
</span>
</div>
</div>
</Form>
);
}}
</Formik>
);
};
export default NotificationsLunaSea;

View File

@@ -6,7 +6,6 @@ import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { isValidURL } from '@app/utils/urlValidationHelper';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import type { NotificationAgentNtfy } from '@server/lib/settings';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
@@ -19,7 +18,6 @@ const messages = defineMessages(
'components.Settings.Notifications.NotificationsNtfy',
{
agentenabled: 'Enable Agent',
embedPoster: 'Embed Poster',
url: 'Server root URL',
topic: 'Topic',
usernamePasswordAuth: 'Username + Password authentication',
@@ -46,7 +44,7 @@ const NotificationsNtfy = () => {
data,
error,
mutate: revalidate,
} = useSWR<NotificationAgentNtfy>('/api/v1/settings/notifications/ntfy');
} = useSWR('/api/v1/settings/notifications/ntfy');
const NotificationsNtfySchema = Yup.object().shape({
url: Yup.string()
@@ -80,23 +78,21 @@ const NotificationsNtfy = () => {
return (
<Formik
initialValues={{
enabled: data?.enabled,
embedPoster: data?.embedPoster,
types: data?.types,
url: data?.options.url,
topic: data?.options.topic,
authMethodUsernamePassword: data?.options.authMethodUsernamePassword,
username: data?.options.username,
password: data?.options.password,
authMethodToken: data?.options.authMethodToken,
token: data?.options.token,
enabled: data.enabled,
types: data.types,
url: data.options.url,
topic: data.options.topic,
authMethodUsernamePassword: data.options.authMethod,
username: data.options.username,
password: data.options.password,
authMethodToken: data.options.authMethodToken,
token: data.options.token,
}}
validationSchema={NotificationsNtfySchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/ntfy', {
enabled: values.enabled,
embedPoster: values.embedPoster,
types: values.types,
options: {
url: values.url,
@@ -191,14 +187,6 @@ const NotificationsNtfy = () => {
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
<div className="form-row">
<label htmlFor="embedPoster" className="checkbox-label">
{intl.formatMessage(messages.embedPoster)}
</label>
<div className="form-input-area">
<Field type="checkbox" id="embedPoster" name="embedPoster" />
</div>
</div>
<div className="form-row">
<label htmlFor="url" className="text-label">
{intl.formatMessage(messages.url)}
@@ -314,7 +302,7 @@ const NotificationsNtfy = () => {
</div>
)}
<NotificationTypeSelector
currentTypes={values.enabled ? values.types || 0 : 0}
currentTypes={values.enabled ? values.types : 0}
onUpdate={(newTypes) => {
setFieldValue('types', newTypes);
setFieldTouched('types');

View File

@@ -17,7 +17,6 @@ const messages = defineMessages(
'components.Settings.Notifications.NotificationsPushover',
{
agentenabled: 'Enable Agent',
embedPoster: 'Embed Poster',
accessToken: 'Application API Token',
accessTokenTip:
'<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Jellyseerr',
@@ -87,7 +86,6 @@ const NotificationsPushover = () => {
<Formik
initialValues={{
enabled: data?.enabled,
embedPoster: data?.embedPoster,
types: data?.types,
accessToken: data?.options.accessToken,
userToken: data?.options.userToken,
@@ -98,7 +96,6 @@ const NotificationsPushover = () => {
try {
await axios.post('/api/v1/settings/notifications/pushover', {
enabled: values.enabled,
embedPoster: values.embedPoster,
types: values.types,
options: {
accessToken: values.accessToken,
@@ -145,7 +142,6 @@ const NotificationsPushover = () => {
);
await axios.post('/api/v1/settings/notifications/pushover/test', {
enabled: true,
embedPoster: values.embedPoster,
types: values.types,
options: {
accessToken: values.accessToken,
@@ -185,14 +181,6 @@ const NotificationsPushover = () => {
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
<div className="form-row">
<label htmlFor="embedPoster" className="checkbox-label">
{intl.formatMessage(messages.embedPoster)}
</label>
<div className="form-input-area">
<Field type="checkbox" id="embedPoster" name="embedPoster" />
</div>
</div>
<div className="form-row">
<label htmlFor="accessToken" className="text-label">
{intl.formatMessage(messages.accessToken)}

View File

@@ -16,7 +16,6 @@ const messages = defineMessages(
'components.Settings.Notifications.NotificationsSlack',
{
agentenabled: 'Enable Agent',
embedPoster: 'Embed Poster',
webhookUrl: 'Webhook URL',
webhookUrlTip:
'Create an <WebhookLink>Incoming Webhook</WebhookLink> integration',
@@ -60,7 +59,6 @@ const NotificationsSlack = () => {
<Formik
initialValues={{
enabled: data.enabled,
embedPoster: data.embedPoster,
types: data.types,
webhookUrl: data.options.webhookUrl,
}}
@@ -69,7 +67,6 @@ const NotificationsSlack = () => {
try {
await axios.post('/api/v1/settings/notifications/slack', {
enabled: values.enabled,
embedPoster: values.embedPoster,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
@@ -114,7 +111,6 @@ const NotificationsSlack = () => {
);
await axios.post('/api/v1/settings/notifications/slack/test', {
enabled: true,
embedPoster: values.embedPoster,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
@@ -152,14 +148,6 @@ const NotificationsSlack = () => {
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
<div className="form-row">
<label htmlFor="embedPoster" className="checkbox-label">
{intl.formatMessage(messages.embedPoster)}
</label>
<div className="form-input-area">
<Field type="checkbox" id="embedPoster" name="embedPoster" />
</div>
</div>
<div className="form-row">
<label htmlFor="name" className="text-label">
{intl.formatMessage(messages.webhookUrl)}

View File

@@ -15,7 +15,6 @@ import * as Yup from 'yup';
const messages = defineMessages('components.Settings.Notifications', {
agentenabled: 'Enable Agent',
embedPoster: 'Embed Poster',
botUsername: 'Bot Username',
botUsernameTip:
'Allow users to also start a chat with your bot and configure their own notifications',
@@ -90,7 +89,6 @@ const NotificationsTelegram = () => {
<Formik
initialValues={{
enabled: data?.enabled,
embedPoster: data?.embedPoster,
types: data?.types,
botUsername: data?.options.botUsername,
botAPI: data?.options.botAPI,
@@ -103,7 +101,6 @@ const NotificationsTelegram = () => {
try {
await axios.post('/api/v1/settings/notifications/telegram', {
enabled: values.enabled,
embedPoster: values.embedPoster,
types: values.types,
options: {
botAPI: values.botAPI,
@@ -194,14 +191,6 @@ const NotificationsTelegram = () => {
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
<div className="form-row">
<label htmlFor="embedPoster" className="checkbox-label">
{intl.formatMessage(messages.embedPoster)}
</label>
<div className="form-input-area">
<Field type="checkbox" id="embedPoster" name="embedPoster" />
</div>
</div>
<div className="form-row">
<label htmlFor="botAPI" className="text-label">
{intl.formatMessage(messages.botAPI)}

View File

@@ -15,7 +15,6 @@ const messages = defineMessages(
'components.Settings.Notifications.NotificationsWebPush',
{
agentenabled: 'Enable Agent',
embedPoster: 'Embed Poster',
webpushsettingssaved: 'Web push notification settings saved successfully!',
webpushsettingsfailed: 'Web push notification settings failed to save.',
toastWebPushTestSending: 'Sending web push test notification…',
@@ -56,13 +55,11 @@ const NotificationsWebPush = () => {
<Formik
initialValues={{
enabled: data.enabled,
embedPoster: data.embedPoster,
}}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/webpush', {
enabled: values.enabled,
embedPoster: values.embedPoster,
options: {},
});
mutate('/api/v1/settings/public');
@@ -80,7 +77,7 @@ const NotificationsWebPush = () => {
}
}}
>
{({ isSubmitting, values }) => {
{({ isSubmitting }) => {
const testSettings = async () => {
setIsTesting(true);
let toastId: string | undefined;
@@ -97,7 +94,6 @@ const NotificationsWebPush = () => {
);
await axios.post('/api/v1/settings/notifications/webpush/test', {
enabled: true,
embedPoster: values.embedPoster,
options: {},
});
@@ -132,15 +128,6 @@ const NotificationsWebPush = () => {
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
<div className="form-row">
<label htmlFor="embedPoster" className="checkbox-label">
{intl.formatMessage(messages.embedPoster)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<Field type="checkbox" id="embedPoster" name="embedPoster" />
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">

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