Compare commits

..

21 Commits

Author SHA1 Message Date
Gauthier
3c8fb4f4bb fix: correct sonarr override rules 2024-11-19 23:00:28 +01:00
Gauthier
98ecdf16d2 fix: add missing migration 2024-11-18 22:07:25 +01:00
Gauthier
a8bd51f8ab style: run prettier 2024-11-17 20:25:52 +01:00
Gauthier
6ee96604ec fix: resolve type errors 2024-11-17 20:25:50 +01:00
Gauthier
aa3670322d fix: save the settings modified by the override rules 2024-11-17 20:25:49 +01:00
Gauthier
38eaa30cee feat: add users to override rules 2024-11-17 20:25:48 +01:00
Gauthier
c0fce76db7 feat: apply override rules in the media request 2024-11-17 20:25:46 +01:00
Gauthier
92341c8d06 feat: add support for sonarr and keywords to override rules 2024-11-17 20:25:45 +01:00
Gauthier
cbcb71436d feat: create the basis for the override rules 2024-11-17 20:25:43 +01:00
Gauthier
39a5ccb7f3 fix(usersettings): allow unset email and add more explicit email error message (#1096) 2024-11-16 16:55:38 +01:00
Gauthier
9b151feb4f fix(ui): allow thetvdb images for unmatched series (#1105)
When a series has no equivalent in TheTVDB used by Sonarr, a popup is displayed to select the series
on TheTVDB. The images in this popup come from artworks.thetvdb.com, which was not an authorized
domain for Next.js images.

fix #1075
2024-11-16 15:33:26 +01:00
Gauthier
fe5d016929 fix(ui): resize streaming service logos (#1106)
The size of streaming service logos has been changed due to the Next.js Image component update.

fix #1103
2024-11-16 15:26:48 +01:00
Gauthier
14f316a9a6 fix: use less strict validation for external URLs (#1104)
* fix: use less strict validation for external URLs

Default url validation from the Yup module doesn't allow URLs like "http://custom-host", while it is
a correct value for an external URL.

fix #1068

* fix: resolve GitHub CodeQL review
2024-11-16 15:26:31 +01:00
Guillaume ARNOUX
5c24e79b1d feat(notifications): improve discord notifications (#1102)
* feat: improve discord notifications

Added a field in the general notification settings to allow a role to be mentioned in the webhook
message via discord notification agent

* feat: add discord role id notification - locales
2024-11-15 18:38:23 +01:00
Joe
ba84212e68 docs: added missing sub_filters in nginx subpath (#1052)
* Update reverse-proxy.mdx

Fix posters not showing up when using sub-folder (at least with NPM)

* Update docs/extending-jellyseerr/reverse-proxy.mdx

Added `avatarproxy` support

Co-authored-by: Gauthier <mail@gauthierth.fr>

---------

Co-authored-by: Gauthier <mail@gauthierth.fr>
2024-11-13 10:54:18 +08:00
Gauthier
f25b32aec8 fix: update i18n translations (#1090) 2024-11-12 22:31:15 +01:00
Fallenbagel
5a13226877 refactor(jellyfinapi): improve logging of jellyfin api (#1089)
This commit improves the logging of jellyfin api. This will also fix the wrong logging of throwing
an invalid credentials when jellyfin/emby is unreachable.

re #1053
2024-11-12 22:26:56 +01:00
Gauthier
694913c767 fix(blacklist): request data only when modal is shown, remove useless ratelimit and lazy load blacklist (#1084)
* perf: remove eager load of Blacklist entity from Media entity

Try to resolve some performance issues by removing the eager loading of Blacklist items from the
Media entity

* fix: fix ManageSlideOver for blacklist

* perf(blacklist): request data only when modal is shown

For admin users, the button to blacklist a media (used on every media card) was displaying a Modal,
that was requesting data BEFORE the modal was displayed. This resulted in dozens of additional
requests everytime media cards were displayed.

* perf(blacklist): remove useless ratelimit
2024-11-13 03:01:06 +08:00
Gauthier
a2d2fd3c2a fix(i18n): update extractMessages function for better escaping of characters (#1079)
This PR fix a bug when a translation message has two single quote like "message": "hello 'world'",
the extractMessages function was escaping the message correcly.
2024-11-13 02:58:33 +08:00
Gauthier
cb94ad5a2e docs: fix pnpm install command (#1086) 2024-11-11 23:31:55 +08:00
Gauthier
2829c2548a fix(setup): add leading slash validation for baseUrl (#1083) 2024-11-11 02:51:45 +08:00
129 changed files with 8660 additions and 5558 deletions

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
4. Run the development environment:
```bash
pnpm
pnpm install
pnpm dev
```

View File

@@ -75,6 +75,7 @@
"types": 0,
"options": {
"webhookUrl": "",
"webhookRoleId": "",
"enableMentions": true
}
},

View File

@@ -95,6 +95,8 @@ location ^~ /jellyseerr {
sub_filter '/api/v1' '/$app/api/v1';
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
sub_filter '/images/' '/$app/images/';
sub_filter '/imageproxy/' '/$app/imageproxy/';
sub_filter '/avatarproxy/' '/$app/avatarproxy/';
sub_filter '/android-' '/$app/android-';
sub_filter '/apple-' '/$app/apple-';
sub_filter '/favicon' '/$app/favicon';

View File

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

View File

@@ -18,6 +18,10 @@ Users can optionally opt-in to being mentioned in Discord notifications by confi
You can find the webhook URL in the Discord application, at **Server Settings &rarr; Integrations &rarr; Webhooks**.
### Notification Role ID (optional)
If a role ID is specified, it will be included in the webhook message. See [Discord role ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID).
### Bot Username (optional)
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!

2
next-env.d.ts vendored
View File

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

View File

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

View File

@@ -1273,6 +1273,8 @@ components:
type: string
webhookUrl:
type: string
webhookRoleId:
type: string
enableMentions:
type: boolean
SlackSettings:
@@ -1930,6 +1932,11 @@ components:
type: string
native_name:
type: string
OverrideRule:
type: object
properties:
id:
type: string
securitySchemes:
cookieAuth:
type: apiKey
@@ -3753,6 +3760,11 @@ paths:
type: string
enum: [created, updated, requests, displayname]
default: created
- in: query
name: q
required: false
schema:
type: string
responses:
'200':
description: A JSON array of all users
@@ -4142,6 +4154,21 @@ paths:
'412':
description: Item has already been blacklisted
/blacklist/{tmdbId}:
get:
summary: Get media from blacklist
tags:
- blacklist
parameters:
- in: path
name: tmdbId
description: tmdbId ID
required: true
example: '1'
schema:
type: string
responses:
'200':
description: Blacklist details in JSON
delete:
summary: Remove media from blacklist
tags:
@@ -6939,6 +6966,68 @@ paths:
type: array
items:
$ref: '#/components/schemas/WatchProviderDetails'
/overrideRule:
get:
summary: Get override rules
description: Returns a list of all override rules with their conditions and settings
tags:
- overriderule
responses:
'200':
description: Override rules returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/OverrideRule'
post:
summary: Create override rule
description: Creates a new Override Rule from the request body.
tags:
- overriderule
responses:
'200':
description: 'Values were successfully created'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/OverrideRule'
/overrideRule/{ruleId}:
put:
summary: Update override rule
description: Updates an Override Rule from the request body.
tags:
- overriderule
responses:
'200':
description: 'Values were successfully updated'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/OverrideRule'
delete:
summary: Delete override rule by ID
description: Deletes the override rule with the provided ruleId.
tags:
- overriderule
parameters:
- in: path
name: ruleId
required: true
schema:
type: number
responses:
'200':
description: Override rule successfully deleted
content:
application/json:
schema:
$ref: '#/components/schemas/OverrideRule'
security:
- cookieAuth: []
- apiKey: []

View File

@@ -43,8 +43,6 @@
"@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.30",
"ace-builds": "1.15.2",
"axios": "1.3.4",
"axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0",
"bowser": "2.11.0",
"connect-typeorm": "1.1.4",
@@ -62,7 +60,6 @@
"express-rate-limit": "6.7.0",
"express-session": "1.17.3",
"formik": "^2.4.6",
"global-agent": "^3.0.0",
"gravatar-url": "3.1.0",
"lodash": "4.17.21",
"mime": "3",

8124
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -109,8 +109,6 @@ class JellyfinAPI extends ExternalAPI {
{
headers: {
'X-Emby-Authorization': authHeaderVal,
'Content-Type': 'application/json',
Accept: 'application/json',
},
}
);
@@ -122,7 +120,7 @@ class JellyfinAPI extends ExternalAPI {
ClientIP?: string
): Promise<JellyfinLoginResponse> {
const authenticate = async (useHeaders: boolean) => {
const headers =
const headers: { [key: string]: string } =
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
return this.post<JellyfinLoginResponse>(
@@ -131,6 +129,8 @@ class JellyfinAPI extends ExternalAPI {
Username,
Pw: Password,
},
{},
undefined,
{ headers }
);
};
@@ -138,39 +138,38 @@ class JellyfinAPI extends ExternalAPI {
try {
return await authenticate(true);
} catch (e) {
logger.debug(`Failed to authenticate with headers: ${e.message}`, {
logger.debug('Failed to authenticate with headers', {
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
ip: ClientIP,
});
if (!e.cause.status) {
throw new ApiError(404, ApiErrorCode.InvalidUrl);
}
if (e.cause.status === 401) {
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
}
}
try {
return await authenticate(false);
} catch (e) {
const status = e.cause?.status;
const networkErrorCodes = new Set([
'ECONNREFUSED',
'EHOSTUNREACH',
'ENOTFOUND',
'ETIMEDOUT',
'ECONNRESET',
'EADDRINUSE',
'ENETDOWN',
'ENETUNREACH',
'EPIPE',
'ECONNABORTED',
'EPROTO',
'EHOSTDOWN',
'EAI_AGAIN',
'ERR_INVALID_URL',
]);
if (networkErrorCodes.has(e.code) || status === 404) {
throw new ApiError(status, ApiErrorCode.InvalidUrl);
if (e.cause.status === 401) {
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
}
throw new ApiError(status, ApiErrorCode.InvalidCredentials);
logger.error(
'Something went wrong while authenticating with the Jellyfin server',
{
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
ip: ClientIP,
}
);
throw new ApiError(e.cause.status, ApiErrorCode.Unknown);
}
}
@@ -198,8 +197,8 @@ class JellyfinAPI extends ExternalAPI {
return serverResponse.ServerName;
} catch (e) {
logger.error(
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting the server name from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
@@ -213,8 +212,8 @@ class JellyfinAPI extends ExternalAPI {
return { users: userReponse };
} catch (e) {
logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting the account from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -229,8 +228,8 @@ class JellyfinAPI extends ExternalAPI {
return userReponse;
} catch (e) {
logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting the account from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -253,8 +252,11 @@ class JellyfinAPI extends ExternalAPI {
return this.mapLibraries(mediaFolderResponse.Items);
} catch (e) {
logger.error(
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting libraries from the Jellyfin server',
{
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
}
);
return [];
@@ -291,7 +293,16 @@ class JellyfinAPI extends ExternalAPI {
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
try {
const libraryItemsResponse = await this.get<any>(
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
`/Users/${this.userId}/Items`,
{
SortBy: 'SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Series,Movie,Others',
Recursive: 'true',
StartIndex: '0',
ParentId: id,
collapseBoxSetItems: 'false',
}
);
return libraryItemsResponse.Items.filter(
@@ -299,8 +310,8 @@ class JellyfinAPI extends ExternalAPI {
);
} catch (e) {
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -310,14 +321,18 @@ class JellyfinAPI extends ExternalAPI {
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
try {
const itemResponse = await this.get<any>(
`/Users/${this.userId}/Items/Latest?Limit=12&ParentId=${id}`
`/Users/${this.userId}/Items/Latest`,
{
Limit: '12',
ParentId: id,
}
);
return itemResponse;
} catch (e) {
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -341,8 +356,8 @@ class JellyfinAPI extends ExternalAPI {
}
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
}
@@ -355,8 +370,8 @@ class JellyfinAPI extends ExternalAPI {
return seasonResponse.Items;
} catch (e) {
logger.error(
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting the list of seasons from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -369,7 +384,10 @@ class JellyfinAPI extends ExternalAPI {
): Promise<JellyfinLibraryItem[]> {
try {
const episodeResponse = await this.get<any>(
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
`/Shows/${seriesID}/Episodes`,
{
seasonId: seasonID,
}
);
return episodeResponse.Items.filter(
@@ -377,8 +395,8 @@ class JellyfinAPI extends ExternalAPI {
);
} catch (e) {
logger.error(
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting the list of episodes from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -394,8 +412,8 @@ class JellyfinAPI extends ExternalAPI {
).AccessToken;
} catch (e) {
logger.error(
`Something went wrong while creating an API key from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while creating an API key from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);

View File

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

View File

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

View File

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

View File

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

View File

@@ -113,9 +113,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getSystemStatus = async (): Promise<SystemStatus> => {
try {
const response = await this.axios.get<SystemStatus>('/system/status');
const data = await this.get<SystemStatus>('/system/status');
return response.data;
return data;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve system status: ${e.message}`
@@ -157,16 +157,15 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
try {
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
const data = await this.get<QueueResponse<QueueItemAppendT>>(
`/queue`,
{
params: {
includeEpisode: true,
},
}
includeEpisode: 'true',
},
0
);
return response.data.records;
return data.records;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve queue: ${e.message}`
@@ -176,9 +175,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getTags = async (): Promise<Tag[]> => {
try {
const response = await this.axios.get<Tag[]>(`/tag`);
const data = await this.get<Tag[]>(`/tag`);
return response.data;
return data;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve tags: ${e.message}`
@@ -188,11 +187,11 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public createTag = async ({ label }: { label: string }): Promise<Tag> => {
try {
const response = await this.axios.post<Tag>(`/tag`, {
const data = await this.post<Tag>(`/tag`, {
label,
});
return response.data;
return data;
} catch (e) {
throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`);
}
@@ -207,10 +206,15 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
options: Record<string, unknown>
): Promise<void> {
try {
await this.axios.post(`/command`, {
name: commandName,
...options,
});
await this.post(
`/command`,
{
name: commandName,
...options,
},
{},
0
);
} catch (e) {
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -80,12 +80,12 @@ export class Blacklist implements BlacklistItem {
status: MediaStatus.BLACKLISTED,
status4k: MediaStatus.BLACKLISTED,
mediaType: blacklistRequest.mediaType,
blacklist: blacklist,
blacklist: Promise.resolve(blacklist),
});
await mediaRepository.save(media);
} else {
media.blacklist = blacklist;
media.blacklist = Promise.resolve(blacklist);
media.status = MediaStatus.BLACKLISTED;
media.status4k = MediaStatus.BLACKLISTED;

View File

@@ -118,10 +118,8 @@ class Media {
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
public issues: Issue[];
@OneToOne(() => Blacklist, (blacklist) => blacklist.media, {
eager: true,
})
public blacklist: Blacklist;
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
public blacklist: Promise<Blacklist>;
@CreateDateColumn()
public createdAt: Date;

View File

@@ -13,6 +13,7 @@ import {
MediaType,
} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule';
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions';
@@ -713,48 +714,6 @@ export class MediaRequest {
return;
}
let rootFolder = radarrSettings.activeDirectory;
let qualityProfile = radarrSettings.activeProfileId;
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
if (
this.rootFolder &&
this.rootFolder !== '' &&
this.rootFolder !== radarrSettings.activeDirectory
) {
rootFolder = this.rootFolder;
logger.info(`Request has an override root folder: ${rootFolder}`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
}
if (
this.profileId &&
this.profileId !== radarrSettings.activeProfileId
) {
qualityProfile = this.profileId;
logger.info(
`Request has an override quality profile ID: ${qualityProfile}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
}
if (this.tags && !isEqual(this.tags, radarrSettings.tags)) {
tags = this.tags;
logger.info(`Request has override tags`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
tagIds: tags,
});
}
const tmdb = new TheMovieDb();
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
@@ -775,6 +734,151 @@ export class MediaRequest {
return;
}
let rootFolder = radarrSettings.activeDirectory;
let qualityProfile = radarrSettings.activeProfileId;
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
const overrideRuleRepository = getRepository(OverrideRule);
const overrideRules = await overrideRuleRepository.find({
where: { radarrServiceId: radarrSettings.id },
});
const appliedOverrideRules = overrideRules.filter((rule) => {
if (
rule.users &&
!rule.users
.split(',')
.some((userId) => Number(userId) === this.requestedBy.id)
) {
return false;
}
if (
rule.genre &&
!rule.genre
.split(',')
.some((genreId) =>
movie.genres.map((genre) => genre.id).includes(Number(genreId))
)
) {
return false;
}
if (
rule.language &&
!rule.language
.split('|')
.some((languageId) => languageId === movie.original_language)
) {
return false;
}
if (
rule.keywords &&
!rule.keywords
.split(',')
.some((keywordId) =>
movie.keywords.keywords
.map((keyword) => keyword.id)
.includes(Number(keywordId))
)
) {
return false;
}
return true;
});
if (
this.rootFolder &&
this.rootFolder !== '' &&
this.rootFolder !== radarrSettings.activeDirectory
) {
rootFolder = this.rootFolder;
logger.info(
`Request has a manually overriden root folder: ${rootFolder}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
} else {
const overrideRootFolder = appliedOverrideRules.find(
(rule) => rule.rootFolder
)?.rootFolder;
if (overrideRootFolder) {
rootFolder = overrideRootFolder;
this.rootFolder = rootFolder;
logger.info(
`Request has an override root folder from override rules: ${rootFolder}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
}
}
if (
this.profileId &&
this.profileId !== radarrSettings.activeProfileId
) {
qualityProfile = this.profileId;
logger.info(
`Request has a manually overriden quality profile ID: ${qualityProfile}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
} else {
const overrideProfileId = appliedOverrideRules.find(
(rule) => rule.profileId
)?.profileId;
if (overrideProfileId) {
qualityProfile = overrideProfileId;
this.profileId = qualityProfile;
logger.info(
`Request has an override quality profile ID from override rules: ${qualityProfile}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
}
}
if (this.tags && !isEqual(this.tags, radarrSettings.tags)) {
tags = this.tags;
logger.info(`Request has manually overriden tags`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
tagIds: tags,
});
} else {
const overrideTags = appliedOverrideRules.find(
(rule) => rule.tags
)?.tags;
if (overrideTags) {
tags = [
...new Set([
...tags,
...overrideTags.split(',').map((tag) => Number(tag)),
]),
];
this.tags = tags;
logger.info(`Request has override tags from override rules`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
tagIds: tags,
});
}
}
const requestRepository = getRepository(MediaRequest);
requestRepository.save(this);
if (radarrSettings.tagRequests) {
let userTag = (await radarr.getTags()).find((v) =>
v.label.startsWith(this.requestedBy.id + ' - ')
@@ -816,7 +920,6 @@ export class MediaRequest {
mediaId: this.media.id,
});
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.APPROVED;
await requestRepository.save(this);
return;
@@ -856,8 +959,6 @@ export class MediaRequest {
await mediaRepository.save(media);
})
.catch(async () => {
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED;
requestRepository.save(this);
@@ -957,6 +1058,7 @@ export class MediaRequest {
throw new Error('Media data not found');
}
const requestRepository = getRepository(MediaRequest);
if (
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) {
@@ -966,7 +1068,6 @@ export class MediaRequest {
mediaId: this.media.id,
});
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.APPROVED;
await requestRepository.save(this);
return;
@@ -981,7 +1082,6 @@ export class MediaRequest {
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
if (!tvdbId) {
const requestRepository = getRepository(MediaRequest);
await mediaRepository.remove(media);
await requestRepository.remove(this);
throw new Error('TVDB ID not found');
@@ -1019,29 +1119,110 @@ export class MediaRequest {
? [...sonarrSettings.tags]
: [];
const overrideRuleRepository = getRepository(OverrideRule);
const overrideRules = await overrideRuleRepository.find({
where: { sonarrServiceId: sonarrSettings.id },
});
const appliedOverrideRules = overrideRules.filter((rule) => {
if (
rule.users &&
!rule.users
.split(',')
.some((userId) => Number(userId) === this.requestedBy.id)
) {
return false;
}
if (
rule.genre &&
!rule.genre
.split(',')
.some((genreId) =>
series.genres.map((genre) => genre.id).includes(Number(genreId))
)
) {
return false;
}
if (
rule.language &&
!rule.language
.split('|')
.some((languageId) => languageId === series.original_language)
) {
return false;
}
if (
rule.keywords &&
!rule.keywords
.split(',')
.some((keywordId) =>
series.keywords.results
.map((keyword) => keyword.id)
.includes(Number(keywordId))
)
) {
return false;
}
return true;
});
if (
this.rootFolder &&
this.rootFolder !== '' &&
this.rootFolder !== rootFolder
) {
rootFolder = this.rootFolder;
logger.info(`Request has an override root folder: ${rootFolder}`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
}
if (this.profileId && this.profileId !== qualityProfile) {
qualityProfile = this.profileId;
logger.info(
`Request has an override quality profile ID: ${qualityProfile}`,
`Request has a manually overriden root folder: ${rootFolder}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
} else {
const overrideRootFolder = appliedOverrideRules.find(
(rule) => rule.rootFolder
)?.rootFolder;
if (overrideRootFolder) {
rootFolder = overrideRootFolder;
this.rootFolder = rootFolder;
logger.info(
`Request has an override root folder from override rules: ${rootFolder}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
}
}
if (this.profileId && this.profileId !== qualityProfile) {
qualityProfile = this.profileId;
logger.info(
`Request has a manually overriden quality profile ID: ${qualityProfile}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
} else {
const overrideProfileId = appliedOverrideRules.find(
(rule) => rule.profileId
)?.profileId;
if (overrideProfileId) {
qualityProfile = overrideProfileId;
this.profileId = qualityProfile;
logger.info(
`Request has an override quality profile ID from override rules: ${qualityProfile}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
}
}
if (
@@ -1061,12 +1242,31 @@ export class MediaRequest {
if (this.tags && !isEqual(this.tags, tags)) {
tags = this.tags;
logger.info(`Request has override tags`, {
logger.info(`Request has manually overriden tags`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
tagIds: tags,
});
} else {
const overrideTags = appliedOverrideRules.find(
(rule) => rule.tags
)?.tags;
if (overrideTags) {
tags = [
...new Set([
...tags,
...overrideTags.split(',').map((tag) => Number(tag)),
]),
];
this.tags = tags;
logger.info(`Request has override tags from override rules`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
tagIds: tags,
});
}
}
if (sonarrSettings.tagRequests) {
@@ -1101,6 +1301,8 @@ export class MediaRequest {
}
}
requestRepository.save(this);
const sonarrSeriesOptions: AddSeriesOptions = {
profileId: qualityProfile,
languageProfileId: languageProfile,
@@ -1137,8 +1339,6 @@ export class MediaRequest {
await mediaRepository.save(media);
})
.catch(async () => {
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED;
requestRepository.save(this);

View File

@@ -0,0 +1,52 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity()
class OverrideRule {
@PrimaryGeneratedColumn()
public id: number;
@Column({ type: 'int', nullable: true })
public radarrServiceId?: number;
@Column({ type: 'int', nullable: true })
public sonarrServiceId?: number;
@Column({ nullable: true })
public users?: string;
@Column({ nullable: true })
public genre?: string;
@Column({ nullable: true })
public language?: string;
@Column({ nullable: true })
public keywords?: string;
@Column({ type: 'int', nullable: true })
public profileId?: number;
@Column({ nullable: true })
public rootFolder?: string;
@Column({ nullable: true })
public tags?: string;
@CreateDateColumn()
public createdAt: Date;
@UpdateDateColumn()
public updatedAt: Date;
constructor(init?: Partial<OverrideRule>) {
Object.assign(this, init);
}
}
export default OverrideRule;

View File

@@ -35,10 +35,17 @@ import * as OpenApiValidator from 'express-openapi-validator';
import type { Store } from 'express-session';
import session from 'express-session';
import next from 'next';
import dns from 'node:dns';
import net from 'node:net';
import path from 'path';
import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs';
if (process.env.forceIpv4First === 'true') {
dns.setDefaultResultOrder('ipv4first');
net.setDefaultAutoSelectFamily(false);
}
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
logger.info(`Starting Overseerr version ${getAppVersion()}`);

View File

@@ -0,0 +1,3 @@
import type OverrideRule from '@server/entity/OverrideRule';
export type OverrideRuleResultsResponse = OverrideRule[];

View File

@@ -106,7 +106,7 @@ class DownloadTracker {
{ label: 'Download Tracker' }
);
}
} catch (e) {
} catch {
logger.error(
`Unable to get queue from Radarr server: ${server.name}`,
{

View File

@@ -1,6 +1,6 @@
import logger from '@server/logger';
import axios from 'axios';
import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
import type { RateLimitOptions } from '@server/utils/rateLimit';
import rateLimit from '@server/utils/rateLimit';
import { createHash } from 'crypto';
import { promises } from 'fs';
import mime from 'mime/lite';
@@ -131,29 +131,33 @@ class ImageProxy {
return 0;
}
private axios;
private fetch: typeof fetch;
private cacheVersion;
private key;
private baseUrl;
private headers: HeadersInit | null = null;
constructor(
key: string,
baseUrl: string,
options: {
cacheVersion?: number;
rateLimitOptions?: rateLimitOptions;
headers?: Record<string, unknown>;
rateLimitOptions?: RateLimitOptions;
headers?: HeadersInit;
} = {}
) {
this.cacheVersion = options.cacheVersion ?? 1;
this.baseUrl = baseUrl;
this.key = key;
this.axios = axios.create({
baseURL: baseUrl,
headers: options.headers,
});
if (options.rateLimitOptions) {
this.axios = rateLimit(this.axios, options.rateLimitOptions);
this.fetch = rateLimit(fetch, {
...options.rateLimitOptions,
});
} else {
this.fetch = fetch;
}
this.headers = options.headers || null;
}
public async getImage(
@@ -245,23 +249,34 @@ class ImageProxy {
): Promise<ImageResponse | null> {
try {
const directory = join(this.getCacheDirectory(), cacheKey);
const response = await this.axios.get(path, {
responseType: 'arraybuffer',
const href =
this.baseUrl +
(this.baseUrl.length > 0
? this.baseUrl.endsWith('/')
? ''
: '/'
: '') +
(path.startsWith('/') ? path.slice(1) : path);
const response = await this.fetch(href, {
headers: this.headers || undefined,
});
const buffer = Buffer.from(response.data, 'binary');
if (!response.ok) {
return null;
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extension = mime.getExtension(
response.headers['Content-Type']?.toString() ?? ''
response.headers.get('content-type') ?? ''
);
let maxAge = Number(
(response.headers['Cache-Control']?.toString() ?? '0').split('=')[1]
(response.headers.get('cache-control') ?? '0').split('=')[1]
);
if (!maxAge) maxAge = 86400;
const expireAt = Date.now() + maxAge * 1000;
const etag = (response.headers.etag ?? '').replace(/"/g, '');
const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
await this.writeToCacheDir(
directory,

View File

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

View File

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

View File

@@ -3,7 +3,6 @@ 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';
@@ -101,19 +100,23 @@ class LunaSeaAgent
});
try {
await axios.post(
settings.options.webhookUrl,
this.buildPayload(type, payload),
settings.options.profileName
const response = await fetch(settings.options.webhookUrl, {
method: 'POST',
headers: settings.options.profileName
? {
headers: {
Authorization: `Basic ${Buffer.from(
`${settings.options.profileName}:`
).toString('base64')}`,
},
'Content-Type': 'application/json',
}
: undefined
);
: {
'Content-Type': 'application/json',
Authorization: `Basic ${Buffer.from(
`${settings.options.profileName}:`
).toString('base64')}`,
},
body: JSON.stringify(this.buildPayload(type, payload)),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
return true;
} catch (e) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -76,6 +76,7 @@ export interface DVRSettings {
syncEnabled: boolean;
preventSearch: boolean;
tagRequests: boolean;
overrideRule: number[];
}
export interface RadarrSettings extends DVRSettings {
@@ -170,6 +171,7 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig {
botUsername?: string;
botAvatarUrl?: string;
webhookUrl: string;
webhookRoleId?: string;
enableMentions: boolean;
};
}
@@ -394,6 +396,7 @@ class Settings {
types: 0,
options: {
webhookUrl: '',
webhookRoleId: '',
enableMentions: true,
},
},

View File

@@ -0,0 +1,15 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddOverrideRules1731963944025 implements MigrationInterface {
name = 'AddOverrideRules1731963944025';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "override_rule"`);
}
}

View File

@@ -2,14 +2,12 @@ import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import Media from '@server/entity/Media';
import { NotFoundError } from '@server/entity/Watchlist';
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { QueryFailedError } from 'typeorm';
import { EntityNotFoundError, QueryFailedError } from 'typeorm';
import { z } from 'zod';
const blacklistRoutes = Router();
@@ -26,7 +24,6 @@ blacklistRoutes.get(
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
type: 'or',
}),
rateLimit({ windowMs: 60 * 1000, max: 50 }),
async (req, res, next) => {
const pageSize = req.query.take ? Number(req.query.take) : 25;
const skip = req.query.skip ? Number(req.query.skip) : 0;
@@ -71,6 +68,32 @@ blacklistRoutes.get(
}
);
blacklistRoutes.get(
'/:id',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
type: 'or',
}),
async (req, res, next) => {
try {
const blacklisteRepository = getRepository(Blacklist);
const blacklistItem = await blacklisteRepository.findOneOrFail({
where: { tmdbId: Number(req.params.id) },
});
return res.status(200).send(blacklistItem);
} catch (e) {
if (e instanceof EntityNotFoundError) {
return next({
status: 401,
message: e.message,
});
}
return next({ status: 500, message: e.message });
}
}
);
blacklistRoutes.post(
'/',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
@@ -134,7 +157,7 @@ blacklistRoutes.delete(
return res.status(204).send();
} catch (e) {
if (e instanceof NotFoundError) {
if (e instanceof EntityNotFoundError) {
return next({
status: 401,
message: e.message,

View File

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

View File

@@ -15,6 +15,7 @@ import { checkUser, isAuthenticated } from '@server/middleware/auth';
import { mapWatchProviderDetails } from '@server/models/common';
import { mapProductionCompany } from '@server/models/Movie';
import { mapNetwork } from '@server/models/Tv';
import overrideRuleRoutes from '@server/routes/overrideRule';
import settingsRoutes from '@server/routes/settings';
import watchlistRoutes from '@server/routes/watchlist';
import {
@@ -160,6 +161,11 @@ router.use('/service', isAuthenticated(), serviceRoutes);
router.use('/issue', isAuthenticated(), issueRoutes);
router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
router.use('/auth', authRoutes);
router.use(
'/overrideRule',
isAuthenticated(Permission.ADMIN),
overrideRuleRoutes
);
router.get('/regions', isAuthenticated(), async (req, res, next) => {
const tmdb = new TheMovieDb();

View File

@@ -0,0 +1,136 @@
import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule';
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
import { Permission } from '@server/lib/permissions';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
const overrideRuleRoutes = Router();
overrideRuleRoutes.get(
'/',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rules = await overrideRuleRepository.find({});
return res.status(200).json(rules as OverrideRuleResultsResponse);
} catch (e) {
next({ status: 404, message: e.message });
}
}
);
overrideRuleRoutes.post<
Record<string, string>,
OverrideRule,
{
users?: string;
genre?: string;
language?: string;
keywords?: string;
profileId?: number;
rootFolder?: string;
tags?: string;
radarrServiceId?: number;
sonarrServiceId?: number;
}
>('/', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rule = new OverrideRule({
users: req.body.users,
genre: req.body.genre,
language: req.body.language,
keywords: req.body.keywords,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
tags: req.body.tags,
radarrServiceId: req.body.radarrServiceId,
sonarrServiceId: req.body.sonarrServiceId,
});
const newRule = await overrideRuleRepository.save(rule);
return res.status(200).json(newRule);
} catch (e) {
next({ status: 404, message: e.message });
}
});
overrideRuleRoutes.put<
{ ruleId: string },
OverrideRule,
{
users?: string;
genre?: string;
language?: string;
keywords?: string;
profileId?: number;
rootFolder?: string;
tags?: string;
radarrServiceId?: number;
sonarrServiceId?: number;
}
>('/:ruleId', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rule = await overrideRuleRepository.findOne({
where: {
id: Number(req.params.ruleId),
},
});
if (!rule) {
return next({ status: 404, message: 'Override Rule not found.' });
}
rule.users = req.body.users;
rule.genre = req.body.genre;
rule.language = req.body.language;
rule.keywords = req.body.keywords;
rule.profileId = req.body.profileId;
rule.rootFolder = req.body.rootFolder;
rule.tags = req.body.tags;
rule.radarrServiceId = req.body.radarrServiceId;
rule.sonarrServiceId = req.body.sonarrServiceId;
const newRule = await overrideRuleRepository.save(rule);
return res.status(200).json(newRule);
} catch (e) {
next({ status: 404, message: e.message });
}
});
overrideRuleRoutes.delete<{ ruleId: string }, OverrideRule>(
'/:ruleId',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rule = await overrideRuleRepository.findOne({
where: {
id: Number(req.params.ruleId),
},
});
if (!rule) {
return next({ status: 404, message: 'Override Rule not found.' });
}
await overrideRuleRepository.remove(rule);
return res.status(200).json(rule);
} catch (e) {
next({ status: 404, message: e.message });
}
}
);
export default overrideRuleRoutes;

View File

@@ -34,8 +34,16 @@ router.get('/', async (req, res, next) => {
try {
const pageSize = req.query.take ? Number(req.query.take) : 10;
const skip = req.query.skip ? Number(req.query.skip) : 0;
const q = req.query.q ? req.query.q.toString().toLowerCase() : '';
let query = getRepository(User).createQueryBuilder('user');
if (q) {
query = query.where(
'LOWER(user.username) LIKE :q OR LOWER(user.email) LIKE :q OR LOWER(user.plexUsername) LIKE :q OR LOWER(user.jellyfinUsername) LIKE :q',
{ q: `%${q}%` }
);
}
switch (req.query.sort) {
case 'updated':
query = query.orderBy('user.updatedAt', 'DESC');

View File

@@ -1,4 +1,5 @@
import { ApiErrorCode } from '@server/constants/error';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { UserSettings } from '@server/entity/UserSettings';
@@ -99,11 +100,29 @@ userSettingsRoutes.post<
});
}
user.username = req.body.username;
const oldEmail = user.email;
const oldUsername = user.username;
user.username = req.body.username;
if (user.jellyfinUsername) {
user.email = req.body.email || user.jellyfinUsername || user.email;
}
// Edge case for local users, because they have no Jellyfin username to fall back on
// if the email is not provided
if (user.userType === UserType.LOCAL) {
if (req.body.email) {
user.email = req.body.email;
if (
!user.username &&
user.email !== oldEmail &&
!oldEmail.includes('@')
) {
user.username = oldEmail;
}
} else if (req.body.username) {
user.email = oldUsername || user.email;
user.username = req.body.username;
}
}
const existingUser = await userRepository.findOne({
where: { email: user.email },

68
server/utils/rateLimit.ts Normal file
View File

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

View File

@@ -23,7 +23,6 @@ import type {
} from '@server/interfaces/api/blacklistInterfaces';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import Link from 'next/link';
import { useRouter } from 'next/router';
import type { ChangeEvent } from 'react';
@@ -239,8 +238,11 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
setIsUpdating(true);
try {
await axios.delete('/api/v1/blacklist/' + tmdbId);
const res = await fetch('/api/v1/blacklist/' + tmdbId, {
method: 'DELETE',
});
if (res.status === 204) {
addToast(
<span>
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
@@ -250,7 +252,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
</span>,
{ appearance: 'success', autoDismiss: true }
);
} catch {
} else {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,

View File

@@ -1,16 +1,17 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Tooltip from '@app/components/Common/Tooltip';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { CalendarIcon, TrashIcon, UserIcon } from '@heroicons/react/24/solid';
import type { Blacklist } from '@server/entity/Blacklist';
import axios from 'axios';
import Link from 'next/link';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('component.BlacklistBlock', {
blacklistedby: 'Blacklisted By',
@@ -18,13 +19,13 @@ const messages = defineMessages('component.BlacklistBlock', {
});
interface BlacklistBlockProps {
blacklistItem: Blacklist;
tmdbId: number;
onUpdate?: () => void;
onDelete?: () => void;
}
const BlacklistBlock = ({
blacklistItem,
tmdbId,
onUpdate,
onDelete,
}: BlacklistBlockProps) => {
@@ -32,12 +33,16 @@ const BlacklistBlock = ({
const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false);
const { addToast } = useToasts();
const { data } = useSWR<Blacklist>(`/api/v1/blacklist/${tmdbId}`);
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
setIsUpdating(true);
try {
await axios.delete('/api/v1/blacklist/' + tmdbId);
const res = await fetch('/api/v1/blacklist/' + tmdbId, {
method: 'DELETE',
});
if (res.status === 204) {
addToast(
<span>
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
@@ -47,7 +52,7 @@ const BlacklistBlock = ({
</span>,
{ appearance: 'success', autoDismiss: true }
);
} catch {
} else {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,
@@ -60,6 +65,14 @@ const BlacklistBlock = ({
setIsUpdating(false);
};
if (!data) {
return (
<>
<LoadingSpinner />
</>
);
}
return (
<div className="px-4 py-3 text-gray-300">
<div className="flex items-center justify-between">
@@ -71,13 +84,13 @@ const BlacklistBlock = ({
<span className="w-40 truncate md:w-auto">
<Link
href={
blacklistItem.user.id === user?.id
data.user.id === user?.id
? '/profile'
: `/users/${blacklistItem.user.id}`
: `/users/${data.user.id}`
}
>
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{blacklistItem.user.displayName}
{data.user.displayName}
</span>
</Link>
</span>
@@ -89,9 +102,7 @@ const BlacklistBlock = ({
>
<Button
buttonType="danger"
onClick={() =>
removeFromBlacklist(blacklistItem.tmdbId, blacklistItem.title)
}
onClick={() => removeFromBlacklist(data.tmdbId, data.title)}
disabled={isUpdating}
>
<TrashIcon className="icon-sm" />
@@ -112,7 +123,7 @@ const BlacklistBlock = ({
<CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
</Tooltip>
<span>
{intl.formatDate(blacklistItem.createdAt, {
{intl.formatDate(data.createdAt, {
year: 'numeric',
month: 'long',
day: 'numeric',

View File

@@ -38,7 +38,7 @@ const BlacklistModal = ({
const intl = useIntl();
const { data, error } = useSWR<TvDetails | MovieDetails>(
`/api/v1/${type}/${tmdbId}`
show ? `/api/v1/${type}/${tmdbId}` : null
);
return (

View File

@@ -7,7 +7,7 @@ import { useLockBodyScroll } from '@app/hooks/useLockBodyScroll';
import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react';
import type { MouseEvent } from 'react';
import React, { Fragment, useRef } from 'react';
import React, { Fragment, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { useIntl } from 'react-intl';
@@ -66,8 +66,12 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
) => {
const intl = useIntl();
const modalRef = useRef<HTMLDivElement>(null);
const backgroundClickableRef = useRef(backgroundClickable); // This ref is used to detect state change inside the useClickOutside hook
useEffect(() => {
backgroundClickableRef.current = backgroundClickable;
}, [backgroundClickable]);
useClickOutside(modalRef, () => {
if (onCancel && backgroundClickable) {
if (onCancel && backgroundClickableRef.current) {
onCancel();
}
});

View File

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

View File

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

View File

@@ -7,7 +7,9 @@ import defineMessages from '@app/utils/defineMessages';
import type { TvResult } from '@server/models/Search';
import { useIntl } from 'react-intl';
const messages = defineMessages('components.DiscoverTvUpcoming', {});
const messages = defineMessages('components.DiscoverTvUpcoming', {
upcomingtv: 'Upcoming Series',
});
const DiscoverTvUpcoming = () => {
const intl = useIntl();

View File

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

View File

@@ -6,7 +6,6 @@ import defineMessages from '@app/utils/defineMessages';
import { Menu, Transition } from '@headlessui/react';
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import type { default as IssueCommentType } from '@server/entity/IssueComment';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Link from 'next/link';
import { Fragment, useState } from 'react';
@@ -49,7 +48,10 @@ const IssueComment = ({
const deleteComment = async () => {
try {
await axios.delete(`/api/v1/issueComment/${comment.id}`);
const res = await fetch(`/api/v1/issueComment/${comment.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
} catch (e) {
// something went wrong deleting the comment
} finally {
@@ -176,9 +178,17 @@ const IssueComment = ({
<Formik
initialValues={{ newMessage: comment.message }}
onSubmit={async (values) => {
await axios.put(`/api/v1/issueComment/${comment.id}`, {
message: values.newMessage,
});
const res = await fetch(
`/api/v1/issueComment/${comment.id}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: values.newMessage }),
}
);
if (!res.ok) throw new Error();
if (onUpdate) {
onUpdate();

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ import {
ClockIcon,
} from '@heroicons/react/24/outline';
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
import axios from 'axios';
import type { LinkProps } from 'next/link';
import Link from 'next/link';
import { forwardRef, Fragment } from 'react';
@@ -39,9 +38,13 @@ const UserDropdown = () => {
const { user, revalidate } = useUser();
const logout = async () => {
const response = await axios.post('/api/v1/auth/logout');
const res = await fetch('/api/v1/auth/logout', {
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (response.data?.status === 'ok') {
if (data?.status === 'ok') {
revalidate();
}
};

View File

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

View File

@@ -5,7 +5,6 @@ import defineMessages from '@app/utils/defineMessages';
import { InformationCircleIcon } from '@heroicons/react/24/solid';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType, ServerType } from '@server/constants/server';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { FormattedMessage, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
@@ -83,10 +82,17 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
port: Yup.number().required(
intl.formatMessage(messages.validationPortRequired)
),
urlBase: Yup.string().matches(
/^(.*[^/])$/,
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
),
urlBase: Yup.string()
.test(
'leading-slash',
intl.formatMessage(messages.validationUrlBaseLeadingSlash),
(value) => !value || value.startsWith('/')
)
.test(
'trailing-slash',
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
(value) => !value || !value.endsWith('/')
),
email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat))
.required(intl.formatMessage(messages.validationemailrequired)),
@@ -114,16 +120,24 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
// if (serverType !== 'Jellyfin' && serverType !== 'Emby') {
// throw new Error('Invalid serverType'); // You can customize the error message
// }
await axios.post('/api/v1/auth/jellyfin', {
username: values.username,
password: values.password,
hostname: values.hostname,
port: values.port,
useSsl: values.useSsl,
urlBase: values.urlBase,
email: values.email,
serverType: serverType,
const res = await fetch('/api/v1/auth/jellyfin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: values.username,
password: values.password,
hostname: values.hostname,
port: values.port,
useSsl: values.useSsl,
urlBase: values.urlBase,
email: values.email,
serverType: serverType,
}),
});
if (!res.ok) throw new Error(res.statusText, { cause: res });
} catch (e) {
let errorData;
try {
@@ -363,11 +377,18 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
validationSchema={LoginSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/auth/jellyfin', {
username: values.username,
password: values.password,
email: values.username,
const res = await fetch('/api/v1/auth/jellyfin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: values.username,
password: values.password,
email: values.username,
}),
});
if (!res.ok) throw new Error();
} catch (e) {
toasts.addToast(
intl.formatMessage(

View File

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

View File

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

View File

@@ -28,7 +28,6 @@ import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfa
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import Link from 'next/link';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
@@ -112,7 +111,10 @@ const ManageSlideOver = ({
const deleteMedia = async () => {
if (data.mediaInfo) {
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
const res = await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
revalidate();
onClose();
}
@@ -120,8 +122,16 @@ const ManageSlideOver = ({
const deleteMediaFile = async () => {
if (data.mediaInfo) {
await axios.delete(`/api/v1/media/${data.mediaInfo.id}/file`);
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
const res1 = await fetch(`/api/v1/media/${data.mediaInfo.id}/file`, {
method: 'DELETE',
});
if (!res1.ok) throw new Error();
const res2 = await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
method: 'DELETE',
});
if (!res2.ok) throw new Error();
revalidate();
onClose();
}
@@ -150,9 +160,16 @@ const ManageSlideOver = ({
const markAvailable = async (is4k = false) => {
if (data.mediaInfo) {
await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, {
is4k,
const res = await fetch(`/api/v1/media/${data.mediaInfo?.id}/available`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
is4k,
}),
});
if (!res.ok) throw new Error();
revalidate();
}
};
@@ -275,7 +292,7 @@ const ManageSlideOver = ({
</h3>
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
<BlacklistBlock
blacklistItem={data.mediaInfo.blacklist}
tmdbId={data.mediaInfo.tmdbId}
onUpdate={() => revalidate()}
onDelete={() => onClose()}
/>

View File

@@ -52,7 +52,6 @@ import { IssueStatus } from '@server/constants/issue';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
import axios from 'axios';
import { countries } from 'country-flag-icons';
import 'country-flag-icons/3x2/flags.css';
import { uniqBy } from 'lodash';
@@ -314,25 +313,20 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
const onClickWatchlistBtn = async (): Promise<void> => {
setIsUpdating(true);
try {
const watchlist = await axios.post('/api/v1/watchlist', {
const res = await fetch('/api/v1/watchlist', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
tmdbId: movie?.id,
mediaType: MediaType.MOVIE,
title: movie?.title,
});
}),
});
if (watchlist.data) {
addToast(
<span>
{intl.formatMessage(messages.watchlistSuccess, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
}
} catch {
if (!res.ok) {
addToast(intl.formatMessage(messages.watchlistError), {
appearance: 'error',
autoDismiss: true,
@@ -342,6 +336,20 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
return;
}
const data = await res.json();
if (data) {
addToast(
<span>
{intl.formatMessage(messages.watchlistSuccess, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
}
setIsUpdating(false);
setToggleWatchlist((prevState) => !prevState);
};
@@ -349,17 +357,22 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
const onClickDeleteWatchlistBtn = async (): Promise<void> => {
setIsUpdating(true);
try {
await axios.delete(`/api/v1/watchlist/${movie?.id}`);
const res = await fetch(`/api/v1/watchlist/${movie?.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
addToast(
<span>
{intl.formatMessage(messages.watchlistDeleted, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'info', autoDismiss: true }
);
if (res.status === 204) {
addToast(
<span>
{intl.formatMessage(messages.watchlistDeleted, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'info', autoDismiss: true }
);
}
} catch (e) {
addToast(intl.formatMessage(messages.watchlistError), {
appearance: 'error',
@@ -374,21 +387,21 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
const onClickHideItemBtn = async (): Promise<void> => {
setIsBlacklistUpdating(true);
try {
await axios.post('/api/v1/blacklist', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
tmdbId: movie?.id,
mediaType: 'movie',
title: movie?.title,
user: user?.id,
}),
});
const res = await fetch('/api/v1/blacklist', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
tmdbId: movie?.id,
mediaType: 'movie',
title: movie?.title,
user: user?.id,
}),
});
if (res.status === 201) {
addToast(
<span>
{intl.formatMessage(globalMessages.blacklistSuccess, {
@@ -400,23 +413,21 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
);
revalidate();
} catch (e) {
if (e?.response?.status === 412) {
addToast(
<span>
{intl.formatMessage(globalMessages.blacklistDuplicateError, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'info', autoDismiss: true }
);
} else {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,
});
}
} else if (res.status === 412) {
addToast(
<span>
{intl.formatMessage(globalMessages.blacklistDuplicateError, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'info', autoDismiss: true }
);
} else {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,
});
}
setIsBlacklistUpdating(false);

View File

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

View File

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

View File

@@ -22,7 +22,6 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer';
@@ -74,7 +73,10 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
});
const deleteRequest = async () => {
await axios.delete(`/api/v1/media/${requestData?.media.id}`);
const res = await fetch(`/api/v1/media/${requestData?.media.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded');
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
};
@@ -259,15 +261,22 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
});
const modifyRequest = async (type: 'approve' | 'decline') => {
const response = await axios.post(`/api/v1/request/${request.id}/${type}`);
const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (response) {
if (data) {
revalidate();
}
};
const deleteRequest = async () => {
await axios.delete(`/api/v1/request/${request.id}`);
const res = await fetch(`/api/v1/request/${request.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
};
@@ -275,9 +284,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
setRetrying(true);
try {
const response = await axios.post(`/api/v1/request/${request.id}/retry`);
const res = await fetch(`/api/v1/request/${request.id}/retry`, {
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (response) {
if (data) {
revalidate();
}
} catch (e) {

View File

@@ -21,7 +21,6 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import Link from 'next/link';
import { useState } from 'react';
import { useInView } from 'react-intersection-observer';
@@ -64,7 +63,10 @@ const RequestItemError = ({
const { hasPermission } = useUser();
const deleteRequest = async () => {
await axios.delete(`/api/v1/media/${requestData?.media.id}`);
const res = await fetch(`/api/v1/media/${requestData?.media.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
revalidateList();
};
@@ -322,27 +324,34 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const [isRetrying, setRetrying] = useState(false);
const modifyRequest = async (type: 'approve' | 'decline') => {
const response = await axios.post(`/api/v1/request/${request.id}/${type}`);
const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (response) {
if (data) {
revalidate();
}
};
const deleteRequest = async () => {
await axios.delete(`/api/v1/request/${request.id}`);
const res = await fetch(`/api/v1/request/${request.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
revalidateList();
};
const deleteMediaFile = async () => {
if (request.media) {
try {
await axios.delete(`/api/v1/media/${request.media.id}/file`);
await axios.delete(`/api/v1/media/${request.media.id}`);
} catch {
/* empty */
}
await fetch(`/api/v1/media/${request.media.id}/file`, {
method: 'DELETE',
});
await fetch(`/api/v1/media/${request.media.id}`, {
method: 'DELETE',
});
revalidateList();
}
};
@@ -351,7 +360,12 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
setRetrying(true);
try {
const result = await axios.post(`/api/v1/request/${request.id}/retry`);
const res = await fetch(`/api/v1/request/${request.id}/retry`, {
method: 'POST',
});
if (!res.ok) throw new Error();
const result = await res.json();
revalidate(result.data);
} catch (e) {
addToast(intl.formatMessage(messages.failedretry), {

View File

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

View File

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

View File

@@ -88,14 +88,14 @@ const SearchByNameModal = ({
tvdbId === item.tvdbId ? 'ring ring-indigo-500' : ''
} `}
>
<div className="flex w-24 flex-none items-center space-x-4">
<div className="relative flex w-24 flex-none items-center space-x-4 self-stretch">
<Image
src={
item.remotePoster ??
'/images/overseerr_poster_not_found.png'
}
alt={item.title}
className="h-100 w-auto rounded-md"
className="w-100 h-auto rounded-md"
fill
/>
</div>

View File

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

View File

@@ -4,7 +4,6 @@ import PageTitle from '@app/components/Common/PageTitle';
import LanguagePicker from '@app/components/Layout/LanguagePicker';
import defineMessages from '@app/utils/defineMessages';
import { ArrowLeftIcon, EnvelopeIcon } from '@heroicons/react/24/solid';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Image from 'next/image';
import Link from 'next/link';
@@ -85,14 +84,18 @@ const ResetPassword = () => {
}}
validationSchema={ResetSchema}
onSubmit={async (values) => {
const response = await axios.post(
`/api/v1/auth/reset-password`,
{
const res = await fetch(`/api/v1/auth/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: values.email,
}
);
}),
});
if (!res.ok) throw new Error();
if (response.status === 200) {
if (res.status === 200) {
setSubmitted(true);
}
}}

View File

@@ -5,7 +5,6 @@ import LanguagePicker from '@app/components/Layout/LanguagePicker';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { LifebuoyIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Form, Formik } from 'formik';
import Image from 'next/image';
import Link from 'next/link';
@@ -100,14 +99,21 @@ const ResetPassword = () => {
}}
validationSchema={ResetSchema}
onSubmit={async (values) => {
const response = await axios.post(
const res = await fetch(
`/api/v1/auth/reset-password/${guid}`,
{
password: values.password,
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
password: values.password,
}),
}
);
if (!res.ok) throw new Error();
if (response.status === 200) {
if (res.status === 200) {
setSubmitted(true);
}
}}

View File

@@ -13,12 +13,12 @@ import type {
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import type {
Keyword,
ProductionCompany,
WatchProviderDetails,
} from '@server/models/common';
import axios from 'axios';
import { orderBy } from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
@@ -30,6 +30,7 @@ const messages = defineMessages('components.Selector', {
searchKeywords: 'Search keywords…',
searchGenres: 'Select genres…',
searchStudios: 'Search studios…',
searchUsers: 'Select users…',
starttyping: 'Starting typing to search.',
nooptions: 'No results.',
showmore: 'Show More',
@@ -76,11 +77,9 @@ export const CompanySelector = ({
return;
}
const response = await axios.get<ProductionCompany>(
`/api/v1/studio/${defaultValue}`
);
const studio = response.data;
const res = await fetch(`/api/v1/studio/${defaultValue}`);
if (!res.ok) throw new Error();
const studio: ProductionCompany = await res.json();
setDefaultDataValue([
{
@@ -98,16 +97,15 @@ export const CompanySelector = ({
return [];
}
const results = await axios.get<TmdbCompanySearchResponse>(
'/api/v1/search/company',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
const res = await fetch(
`/api/v1/search/company?query=${encodeURIExtraParams(inputValue)}`
);
if (!res.ok) {
throw new Error('Network response was not ok');
}
const results: TmdbCompanySearchResponse = await res.json();
return results.data.results.map((result) => ({
return results.results.map((result) => ({
label: result.name,
value: result.id,
}));
@@ -161,11 +159,15 @@ export const GenreSelector = ({
const genres = defaultValue.split(',');
const response = await axios.get<TmdbGenre[]>(`/api/v1/genres/${type}`);
const res = await fetch(`/api/v1/genres/${type}`);
if (!res.ok) {
throw new Error('Network response was not ok');
}
const response: TmdbGenre[] = await res.json();
const genreData = genres
.filter((genre) => response.data.find((gd) => gd.id === Number(genre)))
.map((g) => response.data.find((gd) => gd.id === Number(g)))
.filter((genre) => response.find((gd) => gd.id === Number(genre)))
.map((g) => response.find((gd) => gd.id === Number(g)))
.map((g) => ({
label: g?.name ?? '',
value: g?.id ?? 0,
@@ -178,11 +180,11 @@ export const GenreSelector = ({
}, [defaultValue, type]);
const loadGenreOptions = async (inputValue: string) => {
const results = await axios.get<GenreSliderItem[]>(
`/api/v1/discover/genreslider/${type}`
);
const res = await fetch(`/api/v1/discover/genreslider/${type}`);
if (!res.ok) throw new Error();
const results: GenreSliderItem[] = await res.json();
return results.data
return results
.map((result) => ({
label: result.name,
value: result.id,
@@ -298,11 +300,13 @@ export const KeywordSelector = ({
const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
const res = await fetch(`/api/v1/keyword/${keywordId}`);
if (!res.ok) {
throw new Error('Network response was not ok');
}
const keyword: Keyword = await res.json();
return keyword.data;
return keyword;
})
);
@@ -318,16 +322,15 @@ export const KeywordSelector = ({
}, [defaultValue]);
const loadKeywordOptions = async (inputValue: string) => {
const results = await axios.get<TmdbKeywordSearchResponse>(
'/api/v1/search/keyword',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
const res = await fetch(
`/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`
);
if (!res.ok) {
throw new Error('Network response was not ok');
}
const results: TmdbKeywordSearchResponse = await res.json();
return results.data.results.map((result) => ({
return results.results.map((result) => ({
label: result.name,
value: result.id,
}));
@@ -436,7 +439,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`}
>
<div
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 ${
className={`provider-container w-full cursor-pointer rounded-lg ring-1 ${
isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
@@ -450,18 +453,15 @@ export const WatchProviderSelector = ({
role="button"
tabIndex={0}
>
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
}}
fill
className="rounded-lg"
/>
<div className="relative m-2 aspect-1">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
fill
className="rounded-lg object-contain"
/>
</div>
{isActive && (
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
<CheckCircleIcon className="h-6 w-6" />
@@ -482,7 +482,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`}
>
<div
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
className={`provider-container w-full cursor-pointer rounded-lg ring-1 transition ${
isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
@@ -496,18 +496,15 @@ export const WatchProviderSelector = ({
role="button"
tabIndex={0}
>
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
fill
className="rounded-lg"
/>
<div className="relative m-2 aspect-1">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
fill
className="rounded-lg object-contain"
/>
</div>
{isActive && (
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
<CheckCircleIcon className="h-6 w-6" />
@@ -547,3 +544,77 @@ export const WatchProviderSelector = ({
</>
);
};
export const UserSelector = ({
isMulti,
defaultValue,
onChange,
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
const intl = useIntl();
const [defaultDataValue, setDefaultDataValue] = useState<
{ label: string; value: number }[] | null
>(null);
useEffect(() => {
const loadUsers = async (): Promise<void> => {
if (!defaultValue) {
return;
}
const users = defaultValue.split(',');
const res = await fetch(`/api/v1/user`);
if (!res.ok) {
throw new Error('Network response was not ok');
}
const response: UserResultsResponse = await res.json();
const genreData = users
.filter((u) => response.results.find((user) => user.id === Number(u)))
.map((u) => response.results.find((user) => user.id === Number(u)))
.map((u) => ({
label: u?.displayName ?? '',
value: u?.id ?? 0,
}));
setDefaultDataValue(genreData);
};
loadUsers();
}, [defaultValue]);
const loadUserOptions = async (inputValue: string) => {
const res = await fetch(
`/api/v1/user${inputValue ? `?q=${encodeURIComponent(inputValue)}` : ''}`
);
if (!res.ok) throw new Error();
const results: UserResultsResponse = await res.json();
return results.results
.map((result) => ({
label: result.displayName,
value: result.id,
}))
.filter(({ label }) =>
label.toLowerCase().includes(inputValue.toLowerCase())
);
};
return (
<AsyncSelect
key={`user-select-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]}
defaultOptions
cacheOptions
isMulti={isMulti}
loadOptions={loadUserOptions}
placeholder={intl.formatMessage(messages.searchUsers)}
onChange={(value) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange(value as any);
}}
/>
);
};

View File

@@ -1,7 +1,6 @@
/* eslint-disable no-console */
import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import axios from 'axios';
import { useEffect } from 'react';
const ServiceWorkerSetup = () => {
@@ -26,11 +25,18 @@ const ServiceWorkerSetup = () => {
const parsedSub = JSON.parse(JSON.stringify(sub));
if (parsedSub.keys.p256dh && parsedSub.keys.auth) {
await axios.post('/api/v1/user/registerPushSubscription', {
endpoint: parsedSub.endpoint,
p256dh: parsedSub.keys.p256dh,
auth: parsedSub.keys.auth,
const res = await fetch('/api/v1/user/registerPushSubscription', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
endpoint: parsedSub.endpoint,
p256dh: parsedSub.keys.p256dh,
auth: parsedSub.keys.auth,
}),
});
if (!res.ok) throw new Error();
}
}
})

View File

@@ -5,7 +5,6 @@ import useSettings from '@app/hooks/useSettings';
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';
@@ -20,12 +19,16 @@ const messages = defineMessages('components.Settings.Notifications', {
webhookUrl: 'Webhook URL',
webhookUrlTip:
'Create a <DiscordWebhookLink>webhook integration</DiscordWebhookLink> in your server',
webhookRoleId: 'Notification Role ID',
webhookRoleIdTip:
'The role ID to mention in the webhook message. Leave empty to disable mentions',
discordsettingssaved: 'Discord notification settings saved successfully!',
discordsettingsfailed: 'Discord notification settings failed to save.',
toastDiscordTestSending: 'Sending Discord test notification…',
toastDiscordTestSuccess: 'Discord test notification sent!',
toastDiscordTestFailed: 'Discord test notification failed to send.',
validationUrl: 'You must provide a valid URL',
validationWebhookRoleId: 'You must provide a valid Discord Role ID',
validationTypes: 'You must select at least one notification type',
enableMentions: 'Enable Mentions',
});
@@ -54,6 +57,12 @@ const NotificationsDiscord = () => {
otherwise: Yup.string().nullable(),
})
.url(intl.formatMessage(messages.validationUrl)),
webhookRoleId: Yup.string()
.nullable()
.matches(
/^\d{17,19}$/,
intl.formatMessage(messages.validationWebhookRoleId)
),
});
if (!data && !error) {
@@ -68,21 +77,30 @@ const NotificationsDiscord = () => {
botUsername: data?.options.botUsername,
botAvatarUrl: data?.options.botAvatarUrl,
webhookUrl: data.options.webhookUrl,
webhookRoleId: data?.options.webhookRoleId,
enableMentions: data?.options.enableMentions,
}}
validationSchema={NotificationsDiscordSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/discord', {
enabled: values.enabled,
types: values.types,
options: {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
enableMentions: values.enableMentions,
const res = await fetch('/api/v1/settings/notifications/discord', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: values.enabled,
types: values.types,
options: {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
webhookRoleId: values.webhookRoleId,
enableMentions: values.enableMentions,
},
}),
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.discordsettingssaved), {
appearance: 'success',
@@ -121,16 +139,27 @@ const NotificationsDiscord = () => {
toastId = id;
}
);
await axios.post('/api/v1/settings/notifications/discord/test', {
enabled: true,
types: values.types,
options: {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
enableMentions: values.enableMentions,
},
});
const res = await fetch(
'/api/v1/settings/notifications/discord/test',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: true,
types: values.types,
options: {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
webhookRoleId: values.webhookRoleId,
enableMentions: values.enableMentions,
},
}),
}
);
if (!res.ok) throw new Error();
if (toastId) {
removeToast(toastId);
@@ -238,6 +267,21 @@ const NotificationsDiscord = () => {
)}
</div>
</div>
<div className="form-row">
<label htmlFor="webhookRoleId" className="text-label">
{intl.formatMessage(messages.webhookRoleId)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="webhookRoleId" name="webhookRoleId" type="text" />
</div>
{errors.webhookRoleId &&
touched.webhookRoleId &&
typeof errors.webhookRoleId === 'string' && (
<div className="error">{errors.webhookRoleId}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="enableMentions" className="checkbox-label">
{intl.formatMessage(messages.enableMentions)}

View File

@@ -5,7 +5,6 @@ import SettingsBadge from '@app/components/Settings/SettingsBadge';
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';
@@ -148,24 +147,31 @@ const NotificationsEmail = () => {
validationSchema={NotificationsEmailSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/email', {
enabled: values.enabled,
options: {
userEmailRequired: values.userEmailRequired,
emailFrom: values.emailFrom,
smtpHost: values.smtpHost,
smtpPort: Number(values.smtpPort),
secure: values.encryption === 'implicit',
ignoreTls: values.encryption === 'none',
requireTls: values.encryption === 'opportunistic',
authUser: values.authUser,
authPass: values.authPass,
allowSelfSigned: values.allowSelfSigned,
senderName: values.senderName,
pgpPrivateKey: values.pgpPrivateKey,
pgpPassword: values.pgpPassword,
const res = await fetch('/api/v1/settings/notifications/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: values.enabled,
options: {
userEmailRequired: values.userEmailRequired,
emailFrom: values.emailFrom,
smtpHost: values.smtpHost,
smtpPort: Number(values.smtpPort),
secure: values.encryption === 'implicit',
ignoreTls: values.encryption === 'none',
requireTls: values.encryption === 'opportunistic',
authUser: values.authUser,
authPass: values.authPass,
allowSelfSigned: values.allowSelfSigned,
senderName: values.senderName,
pgpPrivateKey: values.pgpPrivateKey,
pgpPassword: values.pgpPassword,
},
}),
});
if (!res.ok) throw new Error();
mutate('/api/v1/settings/public');
addToast(intl.formatMessage(messages.emailsettingssaved), {
@@ -197,22 +203,32 @@ const NotificationsEmail = () => {
toastId = id;
}
);
await axios.post('/api/v1/settings/notifications/email/test', {
enabled: true,
options: {
emailFrom: values.emailFrom,
smtpHost: values.smtpHost,
smtpPort: Number(values.smtpPort),
secure: values.encryption === 'implicit',
ignoreTls: values.encryption === 'none',
requireTls: values.encryption === 'opportunistic',
authUser: values.authUser,
authPass: values.authPass,
senderName: values.senderName,
pgpPrivateKey: values.pgpPrivateKey,
pgpPassword: values.pgpPassword,
},
});
const res = await fetch(
'/api/v1/settings/notifications/email/test',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: true,
options: {
emailFrom: values.emailFrom,
smtpHost: values.smtpHost,
smtpPort: Number(values.smtpPort),
secure: values.encryption === 'implicit',
ignoreTls: values.encryption === 'none',
requireTls: values.encryption === 'opportunistic',
authUser: values.authUser,
authPass: values.authPass,
senderName: values.senderName,
pgpPrivateKey: values.pgpPrivateKey,
pgpPassword: values.pgpPassword,
},
}),
}
);
if (!res.ok) throw new Error();
if (toastId) {
removeToast(toastId);

View File

@@ -4,7 +4,6 @@ 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/solid';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
import { useIntl } from 'react-intl';
@@ -83,14 +82,21 @@ const NotificationsGotify = () => {
validationSchema={NotificationsGotifySchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/gotify', {
enabled: values.enabled,
types: values.types,
options: {
url: values.url,
token: values.token,
const res = await fetch('/api/v1/settings/notifications/gotify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: values.enabled,
types: values.types,
options: {
url: values.url,
token: values.token,
},
}),
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.gotifysettingssaved), {
appearance: 'success',
autoDismiss: true,
@@ -128,14 +134,24 @@ const NotificationsGotify = () => {
toastId = id;
}
);
await axios.post('/api/v1/settings/notifications/gotify/test', {
enabled: true,
types: values.types,
options: {
url: values.url,
token: values.token,
},
});
const res = await fetch(
'/api/v1/settings/notifications/gotify/test',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: true,
types: values.types,
options: {
url: values.url,
token: values.token,
},
}),
}
);
if (!res.ok) throw new Error();
if (toastId) {
removeToast(toastId);

View File

@@ -4,7 +4,6 @@ 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';
@@ -69,14 +68,21 @@ const NotificationsLunaSea = () => {
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,
const res = await fetch('/api/v1/settings/notifications/lunasea', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: values.enabled,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
profileName: values.profileName,
},
}),
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.settingsSaved), {
appearance: 'success',
autoDismiss: true,
@@ -114,14 +120,24 @@ const NotificationsLunaSea = () => {
toastId = id;
}
);
await axios.post('/api/v1/settings/notifications/lunasea/test', {
enabled: true,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
profileName: values.profileName,
},
});
const res = await fetch(
'/api/v1/settings/notifications/lunasea/test',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: true,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
profileName: values.profileName,
},
}),
}
);
if (!res.ok) throw new Error();
if (toastId) {
removeToast(toastId);

View File

@@ -5,7 +5,6 @@ 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';
@@ -68,14 +67,21 @@ const NotificationsPushbullet = () => {
validationSchema={NotificationsPushbulletSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/pushbullet', {
enabled: values.enabled,
types: values.types,
options: {
accessToken: values.accessToken,
channelTag: values.channelTag,
const res = await fetch('/api/v1/settings/notifications/pushbullet', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: values.enabled,
types: values.types,
options: {
accessToken: values.accessToken,
channelTag: values.channelTag,
},
}),
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.pushbulletSettingsSaved), {
appearance: 'success',
autoDismiss: true,
@@ -113,14 +119,24 @@ const NotificationsPushbullet = () => {
toastId = id;
}
);
await axios.post('/api/v1/settings/notifications/pushbullet/test', {
enabled: true,
types: values.types,
options: {
accessToken: values.accessToken,
channelTag: values.channelTag,
},
});
const res = await fetch(
'/api/v1/settings/notifications/pushbullet/test',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: true,
types: values.types,
options: {
accessToken: values.accessToken,
channelTag: values.channelTag,
},
}),
}
);
if (!res.ok) throw new Error();
if (toastId) {
removeToast(toastId);

View File

@@ -5,7 +5,6 @@ import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import type { PushoverSound } from '@server/api/pushover';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
import { useIntl } from 'react-intl';
@@ -94,14 +93,21 @@ const NotificationsPushover = () => {
validationSchema={NotificationsPushoverSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/pushover', {
enabled: values.enabled,
types: values.types,
options: {
accessToken: values.accessToken,
userToken: values.userToken,
const res = await fetch('/api/v1/settings/notifications/pushover', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: values.enabled,
types: values.types,
options: {
accessToken: values.accessToken,
userToken: values.userToken,
},
}),
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.pushoversettingssaved), {
appearance: 'success',
autoDismiss: true,
@@ -139,16 +145,25 @@ const NotificationsPushover = () => {
toastId = id;
}
);
await axios.post('/api/v1/settings/notifications/pushover/test', {
enabled: true,
types: values.types,
options: {
accessToken: values.accessToken,
userToken: values.userToken,
sound: values.sound,
},
});
const res = await fetch(
'/api/v1/settings/notifications/pushover/test',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: true,
types: values.types,
options: {
accessToken: values.accessToken,
userToken: values.userToken,
sound: values.sound,
},
}),
}
);
if (!res.ok) throw new Error();
if (toastId) {
removeToast(toastId);
}

View File

@@ -4,7 +4,6 @@ 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';
@@ -65,13 +64,20 @@ const NotificationsSlack = () => {
validationSchema={NotificationsSlackSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/slack', {
enabled: values.enabled,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
const res = await fetch('/api/v1/settings/notifications/slack', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: values.enabled,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
},
}),
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.slacksettingssaved), {
appearance: 'success',
autoDismiss: true,
@@ -109,13 +115,23 @@ const NotificationsSlack = () => {
toastId = id;
}
);
await axios.post('/api/v1/settings/notifications/slack/test', {
enabled: true,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
},
});
const res = await fetch(
'/api/v1/settings/notifications/slack/test',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: true,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
},
}),
}
);
if (!res.ok) throw new Error();
if (toastId) {
removeToast(toastId);

View File

@@ -5,7 +5,6 @@ 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';
@@ -84,16 +83,23 @@ const NotificationsTelegram = () => {
validationSchema={NotificationsTelegramSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/telegram', {
enabled: values.enabled,
types: values.types,
options: {
botAPI: values.botAPI,
chatId: values.chatId,
sendSilently: values.sendSilently,
botUsername: values.botUsername,
const res = await fetch('/api/v1/settings/notifications/telegram', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: values.enabled,
types: values.types,
options: {
botAPI: values.botAPI,
chatId: values.chatId,
sendSilently: values.sendSilently,
botUsername: values.botUsername,
},
}),
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.telegramsettingssaved), {
appearance: 'success',
@@ -132,16 +138,26 @@ const NotificationsTelegram = () => {
toastId = id;
}
);
await axios.post('/api/v1/settings/notifications/telegram/test', {
enabled: true,
types: values.types,
options: {
botAPI: values.botAPI,
chatId: values.chatId,
sendSilently: values.sendSilently,
botUsername: values.botUsername,
},
});
const res = await fetch(
'/api/v1/settings/notifications/telegram/test',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: true,
types: values.types,
options: {
botAPI: values.botAPI,
chatId: values.chatId,
sendSilently: values.sendSilently,
botUsername: values.botUsername,
},
}),
}
);
if (!res.ok) throw new Error();
if (toastId) {
removeToast(toastId);

View File

@@ -4,7 +4,6 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
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 { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
@@ -58,10 +57,17 @@ const NotificationsWebPush = () => {
}}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/webpush', {
enabled: values.enabled,
options: {},
const res = await fetch('/api/v1/settings/notifications/webpush', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: values.enabled,
options: {},
}),
});
if (!res.ok) throw new Error();
mutate('/api/v1/settings/public');
addToast(intl.formatMessage(messages.webpushsettingssaved), {
appearance: 'success',
@@ -92,10 +98,20 @@ const NotificationsWebPush = () => {
toastId = id;
}
);
await axios.post('/api/v1/settings/notifications/webpush/test', {
enabled: true,
options: {},
});
const res = await fetch(
'/api/v1/settings/notifications/webpush/test',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: true,
options: {},
}),
}
);
if (!res.ok) throw new Error();
if (toastId) {
removeToast(toastId);

View File

@@ -8,7 +8,6 @@ import {
ArrowPathIcon,
QuestionMarkCircleIcon,
} from '@heroicons/react/24/solid';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import dynamic from 'next/dynamic';
import Link from 'next/link';
@@ -150,15 +149,22 @@ const NotificationsWebhook = () => {
validationSchema={NotificationsWebhookSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/webhook', {
enabled: values.enabled,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
jsonPayload: JSON.stringify(values.jsonPayload),
authHeader: values.authHeader,
const res = await fetch('/api/v1/settings/notifications/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: values.enabled,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
jsonPayload: JSON.stringify(values.jsonPayload),
authHeader: values.authHeader,
},
}),
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.webhooksettingssaved), {
appearance: 'success',
autoDismiss: true,
@@ -207,16 +213,25 @@ const NotificationsWebhook = () => {
toastId = id;
}
);
await axios.post('/api/v1/settings/notifications/webhook/test', {
enabled: true,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
jsonPayload: JSON.stringify(values.jsonPayload),
authHeader: values.authHeader,
},
});
const res = await fetch(
'/api/v1/settings/notifications/webhook/test',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: true,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
jsonPayload: JSON.stringify(values.jsonPayload),
authHeader: values.authHeader,
},
}),
}
);
if (!res.ok) throw new Error();
if (toastId) {
removeToast(toastId);
}

View File

@@ -0,0 +1,391 @@
import Modal from '@app/components/Common/Modal';
import LanguageSelector from '@app/components/LanguageSelector';
import {
GenreSelector,
KeywordSelector,
UserSelector,
} from '@app/components/Selector';
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import type OverrideRule from '@server/entity/OverrideRule';
import { Field, Formik } from 'formik';
import { useIntl } from 'react-intl';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages('components.Settings.RadarrModal', {
createrule: 'New Override Rule',
editrule: 'Edit Override Rule',
create: 'Create rule',
conditions: 'Conditions',
conditionsDescription:
'Specifies conditions before applying parameter changes. Each field must be validated for the rules to be applied (AND operation). A field is considered verified if any of its properties match (OR operation).',
settings: 'Settings',
settingsDescription:
'Specifies which settings will be changed when the above conditions are met.',
users: 'Users',
genres: 'Genres',
languages: 'Languages',
keywords: 'Keywords',
rootfolder: 'Root Folder',
selectRootFolder: 'Select root folder',
qualityprofile: 'Quality Profile',
selectQualityProfile: 'Select quality profile',
tags: 'Tags',
notagoptions: 'No tags.',
selecttags: 'Select tags',
ruleCreated: 'Override rule created successfully!',
ruleUpdated: 'Override rule updated successfully!',
});
type OptionType = {
value: number;
label: string;
};
interface OverrideRuleModalProps {
rule: OverrideRule | null;
onClose: () => void;
testResponse: DVRTestResponse;
radarrId?: number;
sonarrId?: number;
}
const OverrideRuleModal = ({
onClose,
rule,
testResponse,
radarrId,
sonarrId,
}: OverrideRuleModalProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const { currentSettings } = useSettings();
return (
<Transition
as="div"
appear
show
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Formik
initialValues={{
users: rule?.users,
genre: rule?.genre,
language: rule?.language,
keywords: rule?.keywords,
profileId: rule?.profileId,
rootFolder: rule?.rootFolder,
tags: rule?.tags,
}}
onSubmit={async (values) => {
try {
const submission = {
users: values.users || null,
genre: values.genre || null,
language: values.language || null,
keywords: values.keywords || null,
profileId: Number(values.profileId) || null,
rootFolder: values.rootFolder || null,
tags: values.tags || null,
radarrServiceId: radarrId,
sonarrServiceId: sonarrId,
};
if (!rule) {
const res = await fetch('/api/v1/overrideRule', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(submission),
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.ruleCreated), {
appearance: 'success',
autoDismiss: true,
});
} else {
const res = await fetch(`/api/v1/overrideRule/${rule.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(submission),
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.ruleUpdated), {
appearance: 'success',
autoDismiss: true,
});
}
onClose();
} catch (e) {
// set error here
}
}}
>
{({
errors,
touched,
values,
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
}) => {
return (
<Modal
onCancel={onClose}
okButtonType="primary"
okText={
isSubmitting
? intl.formatMessage(globalMessages.saving)
: rule
? intl.formatMessage(globalMessages.save)
: intl.formatMessage(messages.create)
}
okDisabled={
isSubmitting ||
!isValid ||
(!values.users &&
!values.genre &&
!values.language &&
!values.keywords) ||
(!values.rootFolder && !values.profileId && !values.tags)
}
onOk={() => handleSubmit()}
title={
!rule
? intl.formatMessage(messages.createrule)
: intl.formatMessage(messages.editrule)
}
>
<div className="mb-6">
<h3 className="text-lg font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.conditions)}
</h3>
<p className="description">
{intl.formatMessage(messages.conditionsDescription)}
</p>
<div className="form-row">
<label htmlFor="users" className="text-label">
{intl.formatMessage(messages.users)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<UserSelector
defaultValue={values.users}
isMulti
onChange={(users) => {
setFieldValue(
'users',
users?.map((v) => v.value).join(',')
);
}}
/>
</div>
{errors.users &&
touched.users &&
typeof errors.users === 'string' && (
<div className="error">{errors.users}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="genre" className="text-label">
{intl.formatMessage(messages.genres)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<GenreSelector
type={radarrId ? 'movie' : 'tv'}
defaultValue={values.genre}
isMulti
onChange={(genres) => {
setFieldValue(
'genre',
genres?.map((v) => v.value).join(',')
);
}}
/>
</div>
{errors.genre &&
touched.genre &&
typeof errors.genre === 'string' && (
<div className="error">{errors.genre}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="language" className="text-label">
{intl.formatMessage(messages.languages)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<LanguageSelector
value={values.language}
serverValue={currentSettings.originalLanguage}
setFieldValue={(_key, value) => {
setFieldValue('language', value);
}}
/>
</div>
{errors.language &&
touched.language &&
typeof errors.language === 'string' && (
<div className="error">{errors.language}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="keywords" className="text-label">
{intl.formatMessage(messages.keywords)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<KeywordSelector
defaultValue={values.keywords}
isMulti
onChange={(value) => {
setFieldValue(
'keywords',
value?.map((v) => v.value).join(',')
);
}}
/>
</div>
{errors.keywords &&
touched.keywords &&
typeof errors.keywords === 'string' && (
<div className="error">{errors.keywords}</div>
)}
</div>
</div>
<h3 className="mt-4 text-lg font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.settings)}
</h3>
<p className="description">
{intl.formatMessage(messages.settingsDescription)}
</p>
<div className="form-row">
<label htmlFor="rootFolderRule" className="text-label">
{intl.formatMessage(messages.rootfolder)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field as="select" id="rootFolderRule" name="rootFolder">
<option value="">
{intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.length > 0 &&
testResponse.rootFolders.map((folder) => (
<option
key={`loaded-profile-${folder.id}`}
value={folder.path}
>
{folder.path}
</option>
))}
</Field>
</div>
{errors.rootFolder &&
touched.rootFolder &&
typeof errors.rootFolder === 'string' && (
<div className="error">{errors.rootFolder}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="profileIdRule" className="text-label">
{intl.formatMessage(messages.qualityprofile)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field as="select" id="profileIdRule" name="profileId">
<option value="">
{intl.formatMessage(messages.selectQualityProfile)}
</option>
{testResponse.profiles.length > 0 &&
testResponse.profiles.map((profile) => (
<option
key={`loaded-profile-${profile.id}`}
value={profile.id}
>
{profile.name}
</option>
))}
</Field>
</div>
{errors.profileId &&
touched.profileId &&
typeof errors.profileId === 'string' && (
<div className="error">{errors.profileId}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
</label>
<div className="form-input-area">
<Select<OptionType, true>
options={testResponse.tags.map((tag) => ({
label: tag.label,
value: tag.id,
}))}
isMulti
placeholder={intl.formatMessage(messages.selecttags)}
className="react-select-container"
classNamePrefix="react-select"
value={
(values?.tags
?.split(',')
.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === Number(tagId)
);
if (!foundTag) {
return undefined;
}
return {
value: foundTag.id,
label: foundTag.label,
};
})
.filter(
(option) => option !== undefined
) as OptionType[]) || []
}
onChange={(value) => {
setFieldValue(
'tags',
value.map((option) => option.value).join(',')
);
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
}
/>
</div>
</div>
</div>
</Modal>
);
}}
</Formik>
</Transition>
);
};
export default OverrideRuleModal;

View File

@@ -0,0 +1,267 @@
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { PencilIcon, TrashIcon } from '@heroicons/react/24/solid';
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
import type OverrideRule from '@server/entity/OverrideRule';
import type { User } from '@server/entity/User';
import type {
Language,
RadarrSettings,
SonarrSettings,
} from '@server/lib/settings';
import type { Keyword } from '@server/models/common';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages('components.Settings.OverrideRuleTile', {
qualityprofile: 'Quality Profile',
rootfolder: 'Root Folder',
tags: 'Tags',
users: 'Users',
genre: 'Genre',
language: 'Language',
keywords: 'Keywords',
conditions: 'Conditions',
settings: 'Settings',
});
interface OverrideRuleTileProps {
rules: OverrideRule[];
setOverrideRuleModal: ({
open,
rule,
testResponse,
}: {
open: boolean;
rule: OverrideRule | null;
testResponse: DVRTestResponse;
}) => void;
testResponse: DVRTestResponse;
radarr?: RadarrSettings | null;
sonarr?: SonarrSettings | null;
revalidate: () => void;
}
const OverrideRuleTile = ({
rules,
setOverrideRuleModal,
testResponse,
radarr,
sonarr,
revalidate,
}: OverrideRuleTileProps) => {
const intl = useIntl();
const [users, setUsers] = useState<User[] | null>(null);
const [keywords, setKeywords] = useState<Keyword[] | null>(null);
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
const { data: genres } = useSWR<TmdbGenre[]>('/api/v1/genres/movie');
useEffect(() => {
(async () => {
const keywords = await Promise.all(
rules
.map((rule) => rule.keywords?.split(','))
.flat()
.filter((keywordId) => keywordId)
.map(async (keywordId) => {
const res = await fetch(`/api/v1/keyword/${keywordId}`);
if (!res.ok) throw new Error();
const keyword: Keyword = await res.json();
return keyword;
})
);
setKeywords(keywords);
const users = await Promise.all(
rules
.map((rule) => rule.users?.split(','))
.flat()
.filter((userId) => userId)
.map(async (userId) => {
const res = await fetch(`/api/v1/user/${userId}`);
if (!res.ok) throw new Error();
const user: User = await res.json();
return user;
})
);
setUsers(users);
})();
}, [rules]);
return (
<>
{rules
.filter(
(rule) =>
(rule.radarrServiceId !== null &&
rule.radarrServiceId === radarr?.id) ||
(rule.sonarrServiceId !== null &&
rule.sonarrServiceId === sonarr?.id)
)
.map((rule) => (
<li className="flex h-full flex-col rounded-lg bg-gray-800 text-left shadow ring-1 ring-gray-500">
<div className="flex w-full flex-1 items-center justify-between space-x-6 p-6">
<div className="flex-1 truncate">
<span className="text-lg">
{intl.formatMessage(messages.conditions)}
</span>
{rule.users && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.users)}
</span>
<div className="inline-flex gap-2">
{rule.users.split(',').map((userId) => {
return (
<span>
{
users?.find((user) => user.id === Number(userId))
?.displayName
}
</span>
);
})}
</div>
</p>
)}
{rule.genre && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.genre)}
</span>
<div className="inline-flex gap-2">
{rule.genre.split(',').map((genreId) => (
<span>
{genres?.find((g) => g.id === Number(genreId))?.name}
</span>
))}
</div>
</p>
)}
{rule.language && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.language)}
</span>
<div className="inline-flex gap-2">
{rule.language
.split('|')
.filter((languageId) => languageId !== 'server')
.map((languageId) => {
const language = languages?.find(
(language) => language.iso_639_1 === languageId
);
if (!language) return null;
const languageName =
intl.formatDisplayName(language.iso_639_1, {
type: 'language',
fallback: 'none',
}) ?? language.english_name;
return <span>{languageName}</span>;
})}
</div>
</p>
)}
{rule.keywords && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.keywords)}
</span>
<div className="inline-flex gap-2">
{rule.keywords.split(',').map((keywordId) => {
return (
<span>
{
keywords?.find(
(keyword) => keyword.id === Number(keywordId)
)?.name
}
</span>
);
})}
</div>
</p>
)}
<span className="text-lg">
{intl.formatMessage(messages.settings)}
</span>
{rule.profileId && (
<p className="runcate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.qualityprofile)}
</span>
{
testResponse.profiles.find(
(profile) => rule.profileId === profile.id
)?.name
}
</p>
)}
{rule.rootFolder && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.rootfolder)}
</span>
{rule.rootFolder}
</p>
)}
{rule.tags && rule.tags.length > 0 && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.tags)}
</span>
<div className="inline-flex gap-2">
{rule.tags.split(',').map((tag) => (
<span>
{
testResponse.tags?.find((t) => t.id === Number(tag))
?.label
}
</span>
))}
</div>
</p>
)}
</div>
</div>
<div className="border-t border-gray-500">
<div className="-mt-px flex">
<div className="flex w-0 flex-1 border-r border-gray-500">
<button
onClick={() =>
setOverrideRuleModal({ open: true, rule, testResponse })
}
className="focus:ring-blue relative -mr-px inline-flex w-0 flex-1 items-center justify-center rounded-bl-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
>
<PencilIcon className="mr-2 h-5 w-5" />
<span>{intl.formatMessage(globalMessages.edit)}</span>
</button>
</div>
<div className="-ml-px flex w-0 flex-1">
<button
onClick={async () => {
const res = await fetch(
`/api/v1/overrideRule/${rule.id}`,
{
method: 'DELETE',
}
);
if (!res.ok) throw new Error();
revalidate();
}}
className="focus:ring-blue relative inline-flex w-0 flex-1 items-center justify-center rounded-br-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
>
<TrashIcon className="mr-2 h-5 w-5" />
<span>{intl.formatMessage(globalMessages.delete)}</span>
</button>
</div>
</div>
</div>
</li>
))}
</>
);
};
export default OverrideRuleTile;

View File

@@ -1,15 +1,21 @@
import Button from '@app/components/Common/Button';
import Modal from '@app/components/Common/Modal';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import OverrideRuleTile from '@app/components/Settings/OverrideRule/OverrideRuleTile';
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { PlusIcon } from '@heroicons/react/24/solid';
import type OverrideRule from '@server/entity/OverrideRule';
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
import type { RadarrSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
type OptionType = {
@@ -70,41 +76,46 @@ const messages = defineMessages('components.Settings.RadarrModal', {
announced: 'Announced',
inCinemas: 'In Cinemas',
released: 'Released',
overrideRules: 'Override Rules',
addrule: 'New Override Rule',
});
interface TestResponse {
profiles: {
id: number;
name: string;
}[];
rootFolders: {
id: number;
path: string;
}[];
tags: {
id: number;
label: string;
}[];
urlBase?: string;
}
interface RadarrModalProps {
radarr: RadarrSettings | null;
onClose: () => void;
onSave: () => void;
overrideRuleModal: { open: boolean; rule: OverrideRule | null };
setOverrideRuleModal: ({
open,
rule,
testResponse,
}: {
open: boolean;
rule: OverrideRule | null;
testResponse: DVRTestResponse;
}) => void;
}
const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
const RadarrModal = ({
onClose,
radarr,
onSave,
overrideRuleModal,
setOverrideRuleModal,
}: RadarrModalProps) => {
const intl = useIntl();
const { data: rules, mutate: revalidate } =
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
const initialLoad = useRef(false);
const { addToast } = useToasts();
const [isValidated, setIsValidated] = useState(radarr ? true : false);
const [isTesting, setIsTesting] = useState(false);
const [testResponse, setTestResponse] = useState<TestResponse>({
const [testResponse, setTestResponse] = useState<DVRTestResponse>({
profiles: [],
rootFolders: [],
tags: [],
});
const RadarrSettingsSchema = Yup.object().shape({
name: Yup.string().required(
intl.formatMessage(messages.validationNameRequired)
@@ -131,7 +142,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
),
externalUrl: Yup.string()
.url(intl.formatMessage(messages.validationApplicationUrl))
.matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationApplicationUrl)
)
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
@@ -166,19 +180,24 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
}) => {
setIsTesting(true);
try {
const response = await axios.post<TestResponse>(
'/api/v1/settings/radarr/test',
{
const res = await fetch('/api/v1/settings/radarr/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
hostname,
apiKey,
port: Number(port),
baseUrl,
useSsl,
}
);
}),
});
if (!res.ok) throw new Error();
const data = await res.json();
setIsValidated(true);
setTestResponse(response.data);
setTestResponse(data);
if (initialLoad.current) {
addToast(intl.formatMessage(messages.toastRadarrTestSuccess), {
appearance: 'success',
@@ -213,6 +232,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
}
}, [radarr, testConnection]);
useEffect(() => {
revalidate();
}, [overrideRuleModal, revalidate]);
return (
<Transition
as="div"
@@ -271,12 +294,23 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
tagRequests: values.tagRequests,
};
if (!radarr) {
await axios.post('/api/v1/settings/radarr', submission);
const res = await fetch('/api/v1/settings/radarr', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(submission),
});
if (!res.ok) throw new Error();
} else {
await axios.put(
`/api/v1/settings/radarr/${radarr.id}`,
submission
);
const res = await fetch(`/api/v1/settings/radarr/${radarr.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(submission),
});
if (!res.ok) throw new Error();
}
onSave();
@@ -345,6 +379,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
values.is4k ? messages.edit4kradarr : messages.editradarr
)
}
backgroundClickable={!overrideRuleModal.open}
>
<div className="mb-6">
<div className="form-row">
@@ -735,6 +770,38 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
</div>
</div>
</div>
<h3 className="mb-4 text-xl font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.overrideRules)}
</h3>
<ul className="grid grid-cols-2 gap-6">
{rules && (
<OverrideRuleTile
rules={rules}
setOverrideRuleModal={setOverrideRuleModal}
testResponse={testResponse}
radarr={radarr}
revalidate={revalidate}
/>
)}
<li className="min-h-[8rem] rounded-lg border-2 border-dashed border-gray-400 shadow sm:min-h-[11rem]">
<div className="flex h-full w-full items-center justify-center">
<Button
buttonType="ghost"
onClick={() =>
setOverrideRuleModal({
open: true,
rule: null,
testResponse,
})
}
disabled={!isValidated}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addrule)}</span>
</Button>
</div>
</li>
</ul>
</Modal>
);
}}

View File

@@ -10,7 +10,6 @@ import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType } from '@server/constants/server';
import type { JellyfinSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Formik } from 'formik';
import { useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
@@ -140,7 +139,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
),
jellyfinExternalUrl: Yup.string()
.nullable()
.url(intl.formatMessage(messages.validationUrl))
.matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationUrl)
)
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash),
@@ -148,7 +150,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
),
jellyfinForgotPasswordUrl: Yup.string()
.nullable()
.url(intl.formatMessage(messages.validationUrl))
.matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationUrl)
)
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash),
@@ -173,9 +178,14 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
}
try {
await axios.get('/api/v1/settings/jellyfin/library', {
params,
const searchParams = new URLSearchParams({
sync: params.sync ? 'true' : 'false',
...(params.enable ? { enable: params.enable } : {}),
});
const res = await fetch(
`/api/v1/settings/jellyfin/library?${searchParams.toString()}`
);
if (!res.ok) throw new Error(res.statusText, { cause: res });
setIsSyncing(false);
revalidate();
} catch (e) {
@@ -219,16 +229,32 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
};
const startScan = async () => {
await axios.post('/api/v1/settings/jellyfin/sync', {
start: true,
const res = await fetch('/api/v1/settings/jellyfin/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
start: true,
}),
});
if (!res.ok) throw new Error();
revalidateSync();
};
const cancelScan = async () => {
await axios.post('/api/v1/settings/jellyfin/sync', {
cancel: true,
const res = await fetch('/api/v1/settings/jellyfin/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
cancel: true,
}),
});
if (!res.ok) throw new Error();
revalidateSync();
};
@@ -243,15 +269,19 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
.join(',');
}
await axios.get('/api/v1/settings/jellyfin/library', {
params,
});
const searchParams = new URLSearchParams(params.enable ? params : {});
const res = await fetch(
`/api/v1/settings/jellyfin/library?${searchParams.toString()}`
);
if (!res.ok) throw new Error();
} else {
await axios.get('/api/v1/settings/jellyfin/library', {
params: {
enable: [...activeLibraries, libraryId].join(','),
},
const searchParams = new URLSearchParams({
enable: [...activeLibraries, libraryId].join(','),
});
const res = await fetch(
`/api/v1/settings/jellyfin/library?${searchParams.toString()}`
);
if (!res.ok) throw new Error();
}
if (onComplete) {
onComplete();
@@ -461,15 +491,22 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
validationSchema={JellyfinSettingsSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/jellyfin', {
ip: values.hostname,
port: Number(values.port),
useSsl: values.useSsl,
urlBase: values.urlBase,
externalHostname: values.jellyfinExternalUrl,
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
apiKey: values.apiKey,
} as JellyfinSettings);
const res = await fetch('/api/v1/settings/jellyfin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
ip: values.hostname,
port: Number(values.port),
useSsl: values.useSsl,
urlBase: values.urlBase,
externalHostname: values.jellyfinExternalUrl,
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
apiKey: values.apiKey,
} as JellyfinSettings),
});
if (!res.ok) throw new Error(res.statusText, { cause: res });
addToast(
intl.formatMessage(

View File

@@ -20,7 +20,6 @@ import type {
CacheResponse,
} from '@server/interfaces/api/settingsInterfaces';
import type { JobId } from '@server/lib/settings';
import axios from 'axios';
import cronstrue from 'cronstrue/i18n';
import { Fragment, useReducer, useState } from 'react';
import type { MessageDescriptor } from 'react-intl';
@@ -189,7 +188,10 @@ const SettingsJobs = () => {
}
const runJob = async (job: Job) => {
await axios.post(`/api/v1/settings/jobs/${job.id}/run`);
const res = await fetch(`/api/v1/settings/jobs/${job.id}/run`, {
method: 'POST',
});
if (!res.ok) throw new Error();
addToast(
intl.formatMessage(messages.jobstarted, {
jobname: intl.formatMessage(messages[job.id] ?? messages.unknownJob),
@@ -203,7 +205,10 @@ const SettingsJobs = () => {
};
const cancelJob = async (job: Job) => {
await axios.post(`/api/v1/settings/jobs/${job.id}/cancel`);
const res = await fetch(`/api/v1/settings/jobs/${job.id}/cancel`, {
method: 'POST',
});
if (!res.ok) throw new Error();
addToast(
intl.formatMessage(messages.jobcancelled, {
jobname: intl.formatMessage(messages[job.id] ?? messages.unknownJob),
@@ -217,7 +222,10 @@ const SettingsJobs = () => {
};
const flushCache = async (cache: CacheItem) => {
await axios.post(`/api/v1/settings/cache/${cache.id}/flush`);
const res = await fetch(`/api/v1/settings/cache/${cache.id}/flush`, {
method: 'POST',
});
if (!res.ok) throw new Error();
addToast(
intl.formatMessage(messages.cacheflushed, { cachename: cache.name }),
{
@@ -244,12 +252,19 @@ const SettingsJobs = () => {
}
setIsSaving(true);
await axios.post(
const res = await fetch(
`/api/v1/settings/jobs/${jobModalState.job.id}/schedule`,
{
schedule: jobScheduleCron.join(' '),
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
schedule: jobScheduleCron.join(' '),
}),
}
);
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.jobScheduleEditSaved), {
appearance: 'success',

View File

@@ -17,7 +17,6 @@ import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import { ArrowPathIcon } from '@heroicons/react/24/solid';
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
import type { MainSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
@@ -88,7 +87,10 @@ const SettingsMain = () => {
intl.formatMessage(messages.validationApplicationTitle)
),
applicationUrl: Yup.string()
.url(intl.formatMessage(messages.validationApplicationUrl))
.matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationApplicationUrl)
)
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
@@ -104,7 +106,10 @@ const SettingsMain = () => {
const regenerate = async () => {
try {
await axios.post('/api/v1/settings/main/regenerate');
const res = await fetch('/api/v1/settings/main/regenerate', {
method: 'POST',
});
if (!res.ok) throw new Error();
revalidate();
addToast(intl.formatMessage(messages.toastApiKeySuccess), {
@@ -165,28 +170,35 @@ const SettingsMain = () => {
validationSchema={MainSettingsSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/main', {
applicationTitle: values.applicationTitle,
applicationUrl: values.applicationUrl,
csrfProtection: values.csrfProtection,
hideAvailable: values.hideAvailable,
locale: values.locale,
region: values.region,
originalLanguage: values.originalLanguage,
partialRequestsEnabled: values.partialRequestsEnabled,
trustProxy: values.trustProxy,
cacheImages: values.cacheImages,
proxy: {
enabled: values.proxyEnabled,
hostname: values.proxyHostname,
port: values.proxyPort,
useSsl: values.proxySsl,
user: values.proxyUser,
password: values.proxyPassword,
bypassFilter: values.proxyBypassFilter,
bypassLocalAddresses: values.proxyBypassLocalAddresses,
const res = await fetch('/api/v1/settings/main', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
applicationTitle: values.applicationTitle,
applicationUrl: values.applicationUrl,
csrfProtection: values.csrfProtection,
hideAvailable: values.hideAvailable,
locale: values.locale,
region: values.region,
originalLanguage: values.originalLanguage,
partialRequestsEnabled: values.partialRequestsEnabled,
trustProxy: values.trustProxy,
cacheImages: values.cacheImages,
proxy: {
enabled: values.proxyEnabled,
hostname: values.proxyHostname,
port: values.proxyPort,
useSsl: values.proxySsl,
user: values.proxyUser,
password: values.proxyPassword,
bypassFilter: values.proxyBypassFilter,
bypassLocalAddresses: values.proxyBypassLocalAddresses,
},
}),
});
if (!res.ok) throw new Error();
mutate('/api/v1/settings/public');
mutate('/api/v1/status');

View File

@@ -16,7 +16,6 @@ import {
} from '@heroicons/react/24/solid';
import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
import type { PlexSettings, TautulliSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Formik } from 'formik';
import { orderBy } from 'lodash';
import { useMemo, useState } from 'react';
@@ -191,7 +190,10 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
otherwise: Yup.string().nullable(),
}),
tautulliExternalUrl: Yup.string()
.url(intl.formatMessage(messages.validationUrl))
.matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationUrl)
)
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash),
@@ -241,9 +243,15 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
params.enable = activeLibraries.join(',');
}
await axios.get('/api/v1/settings/plex/library', {
params,
const searchParams = new URLSearchParams({
sync: params.sync ? 'true' : 'false',
...(params.enable ? { enable: params.enable } : {}),
});
const res = await fetch(
`/api/v1/settings/plex/library?${searchParams.toString()}`
);
if (!res.ok) throw new Error();
setIsSyncing(false);
revalidate();
};
@@ -262,11 +270,12 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
toastId = id;
}
);
const response = await axios.get<PlexDevice[]>(
'/api/v1/settings/plex/devices/servers'
);
if (response.data) {
setAvailableServers(response.data);
const res = await fetch('/api/v1/settings/plex/devices/servers');
if (!res.ok) throw new Error();
const data: PlexDevice[] = await res.json();
if (data) {
setAvailableServers(data);
}
if (toastId) {
removeToast(toastId);
@@ -289,16 +298,30 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
};
const startScan = async () => {
await axios.post('/api/v1/settings/plex/sync', {
start: true,
const res = await fetch('/api/v1/settings/plex/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
start: true,
}),
});
if (!res.ok) throw new Error();
revalidateSync();
};
const cancelScan = async () => {
await axios.post('/api/v1/settings/plex/sync', {
cancel: true,
const res = await fetch('/api/v1/settings/plex/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
cancel: true,
}),
});
if (!res.ok) throw new Error();
revalidateSync();
};
@@ -313,15 +336,19 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
.join(',');
}
await axios.get('/api/v1/settings/plex/library', {
params,
});
const searchParams = new URLSearchParams(params.enable ? params : {});
const res = await fetch(
`/api/v1/settings/plex/library?${searchParams.toString()}`
);
if (!res.ok) throw new Error();
} else {
await axios.get('/api/v1/settings/plex/library', {
params: {
enable: [...activeLibraries, libraryId].join(','),
},
const searchParams = new URLSearchParams({
enable: [...activeLibraries, libraryId].join(','),
});
const res = await fetch(
`/api/v1/settings/plex/library?${searchParams.toString()}`
);
if (!res.ok) throw new Error();
}
setIsSyncing(false);
revalidate();
@@ -385,12 +412,19 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
toastId = id;
}
);
await axios.post('/api/v1/settings/plex', {
ip: values.hostname,
port: Number(values.port),
useSsl: values.useSsl,
webAppUrl: values.webAppUrl,
} as PlexSettings);
const res = await fetch('/api/v1/settings/plex', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
ip: values.hostname,
port: Number(values.port),
useSsl: values.useSsl,
webAppUrl: values.webAppUrl,
} as PlexSettings),
});
if (!res.ok) throw new Error();
syncLibraries();
@@ -748,14 +782,27 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
validationSchema={TautulliSettingsSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/tautulli', {
hostname: values.tautulliHostname,
port: Number(values.tautulliPort),
useSsl: values.tautulliUseSsl,
urlBase: values.tautulliUrlBase,
apiKey: values.tautulliApiKey,
externalUrl: values.tautulliExternalUrl,
} as TautulliSettings);
const res = await fetch('/api/v1/settings/tautulli', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
hostname: values.tautulliHostname,
port: Number(values.tautulliPort),
useSsl: values.tautulliUseSsl,
urlBase: values.tautulliUrlBase,
apiKey: values.tautulliApiKey,
externalUrl: values.tautulliExternalUrl,
} as TautulliSettings),
});
if (!res.ok) throw new Error();
if (!res.ok) {
throw new Error('Failed to fetch');
}
// Continue with any necessary processing
addToast(
intl.formatMessage(messages.toastTautulliSettingsSuccess),

View File

@@ -6,14 +6,15 @@ import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
import PageTitle from '@app/components/Common/PageTitle';
import OverrideRuleModal from '@app/components/Settings/OverrideRule/OverrideRuleModal';
import RadarrModal from '@app/components/Settings/RadarrModal';
import SonarrModal from '@app/components/Settings/SonarrModal';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
import type OverrideRule from '@server/entity/OverrideRule';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import axios from 'axios';
import { Fragment, useState } from 'react';
import { useIntl } from 'react-intl';
import useSWR, { mutate } from 'swr';
@@ -58,6 +59,22 @@ interface ServerInstanceProps {
onDelete: () => void;
}
export interface DVRTestResponse {
profiles: {
id: number;
name: string;
}[];
rootFolders: {
id: number;
path: string;
}[];
tags: {
id: number;
label: string;
}[];
urlBase?: string;
}
const ServerInstance = ({
name,
hostname,
@@ -194,11 +211,25 @@ const SettingsServices = () => {
type: 'radarr',
serverId: null,
});
const [overrideRuleModal, setOverrideRuleModal] = useState<{
open: boolean;
rule: OverrideRule | null;
testResponse: DVRTestResponse | null;
}>({
open: false,
rule: null,
testResponse: null,
});
const deleteServer = async () => {
await axios.delete(
`/api/v1/settings/${deleteServerModal.type}/${deleteServerModal.serverId}`
const res = await fetch(
`/api/v1/settings/${deleteServerModal.type}/${deleteServerModal.serverId}`,
{
method: 'DELETE',
}
);
if (!res.ok) throw new Error();
setDeleteServerModal({ open: false, serverId: null, type: 'radarr' });
revalidateRadarr();
revalidateSonarr();
@@ -223,26 +254,51 @@ const SettingsServices = () => {
})}
</p>
</div>
{overrideRuleModal.open && overrideRuleModal.testResponse && (
<OverrideRuleModal
rule={overrideRuleModal.rule}
onClose={() =>
setOverrideRuleModal({
open: false,
rule: null,
testResponse: null,
})
}
testResponse={overrideRuleModal.testResponse}
radarrId={editRadarrModal.radarr?.id}
sonarrId={editSonarrModal.sonarr?.id}
/>
)}
{editRadarrModal.open && (
<RadarrModal
radarr={editRadarrModal.radarr}
onClose={() => setEditRadarrModal({ open: false, radarr: null })}
onClose={() => {
if (!overrideRuleModal.open)
setEditRadarrModal({ open: false, radarr: null });
}}
onSave={() => {
revalidateRadarr();
mutate('/api/v1/settings/public');
setEditRadarrModal({ open: false, radarr: null });
}}
overrideRuleModal={overrideRuleModal}
setOverrideRuleModal={setOverrideRuleModal}
/>
)}
{editSonarrModal.open && (
<SonarrModal
sonarr={editSonarrModal.sonarr}
onClose={() => setEditSonarrModal({ open: false, sonarr: null })}
onClose={() => {
if (!overrideRuleModal.open)
setEditSonarrModal({ open: false, sonarr: null });
}}
onSave={() => {
revalidateSonarr();
mutate('/api/v1/settings/public');
setEditSonarrModal({ open: false, sonarr: null });
}}
overrideRuleModal={overrideRuleModal}
setOverrideRuleModal={setOverrideRuleModal}
/>
)}
<Transition

View File

@@ -9,7 +9,6 @@ import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import { MediaServerType } from '@server/constants/server';
import type { MainSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
@@ -84,21 +83,28 @@ const SettingsUsers = () => {
enableReinitialize
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/main', {
localLogin: values.localLogin,
newPlexLogin: values.newPlexLogin,
defaultQuotas: {
movie: {
quotaLimit: values.movieQuotaLimit,
quotaDays: values.movieQuotaDays,
},
tv: {
quotaLimit: values.tvQuotaLimit,
quotaDays: values.tvQuotaDays,
},
const res = await fetch('/api/v1/settings/main', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
defaultPermissions: values.defaultPermissions,
body: JSON.stringify({
localLogin: values.localLogin,
newPlexLogin: values.newPlexLogin,
defaultQuotas: {
movie: {
quotaLimit: values.movieQuotaLimit,
quotaDays: values.movieQuotaDays,
},
tv: {
quotaLimit: values.tvQuotaLimit,
quotaDays: values.tvQuotaDays,
},
},
defaultPermissions: values.defaultPermissions,
}),
});
if (!res.ok) throw new Error();
mutate('/api/v1/settings/public');
addToast(intl.formatMessage(messages.toastSettingsSuccess), {

View File

@@ -1,16 +1,22 @@
import Button from '@app/components/Common/Button';
import Modal from '@app/components/Common/Modal';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import OverrideRuleTile from '@app/components/Settings/OverrideRule/OverrideRuleTile';
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { PlusIcon } from '@heroicons/react/24/solid';
import type OverrideRule from '@server/entity/OverrideRule';
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
import type { SonarrSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import type { OnChangeValue } from 'react-select';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
type OptionType = {
@@ -76,48 +82,56 @@ const messages = defineMessages('components.Settings.SonarrModal', {
animeTags: 'Anime Tags',
notagoptions: 'No tags.',
selecttags: 'Select tags',
overrideRules: 'Override Rules',
addrule: 'New Override Rule',
});
interface TestResponse {
profiles: {
id: number;
name: string;
}[];
rootFolders: {
id: number;
path: string;
}[];
interface SonarrTestResponse extends DVRTestResponse {
languageProfiles:
| {
id: number;
name: string;
}[]
| null;
tags: {
id: number;
label: string;
}[];
urlBase?: string;
}
interface SonarrModalProps {
sonarr: SonarrSettings | null;
onClose: () => void;
onSave: () => void;
overrideRuleModal: { open: boolean; rule: OverrideRule | null };
setOverrideRuleModal: ({
open,
rule,
testResponse,
}: {
open: boolean;
rule: OverrideRule | null;
testResponse: DVRTestResponse;
}) => void;
}
const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
const SonarrModal = ({
onClose,
sonarr,
onSave,
overrideRuleModal,
setOverrideRuleModal,
}: SonarrModalProps) => {
const intl = useIntl();
const { data: rules, mutate: revalidate } =
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
const initialLoad = useRef(false);
const { addToast } = useToasts();
const [isValidated, setIsValidated] = useState(sonarr ? true : false);
const [isTesting, setIsTesting] = useState(false);
const [testResponse, setTestResponse] = useState<TestResponse>({
const [testResponse, setTestResponse] = useState<SonarrTestResponse>({
profiles: [],
rootFolders: [],
languageProfiles: null,
tags: [],
});
const SonarrSettingsSchema = Yup.object().shape({
name: Yup.string().required(
intl.formatMessage(messages.validationNameRequired)
@@ -146,7 +160,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
)
: Yup.number(),
externalUrl: Yup.string()
.url(intl.formatMessage(messages.validationApplicationUrl))
.matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationApplicationUrl)
)
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
@@ -181,19 +198,24 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
}) => {
setIsTesting(true);
try {
const response = await axios.post<TestResponse>(
'/api/v1/settings/sonarr/test',
{
const res = await fetch('/api/v1/settings/sonarr/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
hostname,
apiKey,
port: Number(port),
baseUrl,
useSsl,
}
);
}),
});
if (!res.ok) throw new Error();
const data: SonarrTestResponse = await res.json();
setIsValidated(true);
setTestResponse(response.data);
setTestResponse(data);
if (initialLoad.current) {
addToast(intl.formatMessage(messages.toastSonarrTestSuccess), {
appearance: 'success',
@@ -228,6 +250,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
}
}, [sonarr, testConnection]);
useEffect(() => {
revalidate();
}, [overrideRuleModal, revalidate]);
return (
<Transition
as="div"
@@ -310,12 +336,23 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
tagRequests: values.tagRequests,
};
if (!sonarr) {
await axios.post('/api/v1/settings/sonarr', submission);
const res = await fetch('/api/v1/settings/sonarr', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(submission),
});
if (!res.ok) throw new Error();
} else {
await axios.put(
`/api/v1/settings/sonarr/${sonarr.id}`,
submission
);
const res = await fetch(`/api/v1/settings/sonarr/${sonarr.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(submission),
});
if (!res.ok) throw new Error();
}
onSave();
@@ -384,6 +421,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
values.is4k ? messages.edit4ksonarr : messages.editsonarr
)
}
backgroundClickable={!overrideRuleModal.open}
>
<div className="mb-6">
<div className="form-row">
@@ -1038,6 +1076,38 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
</div>
</div>
</div>
<h3 className="mb-4 text-xl font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.overrideRules)}
</h3>
<ul className="grid grid-cols-2 gap-6">
{rules && (
<OverrideRuleTile
rules={rules}
setOverrideRuleModal={setOverrideRuleModal}
testResponse={testResponse}
sonarr={sonarr}
revalidate={revalidate}
/>
)}
<li className="min-h-[8rem] rounded-lg border-2 border-dashed border-gray-400 shadow sm:min-h-[11rem]">
<div className="flex h-full w-full items-center justify-center">
<Button
buttonType="ghost"
onClick={() =>
setOverrideRuleModal({
open: true,
rule: null,
testResponse,
})
}
disabled={!isValidated}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addrule)}</span>
</Button>
</div>
</li>
</ul>
</Modal>
);
}}

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