Compare commits

..

1 Commits

Author SHA1 Message Date
Francisco Sales
cb25ad8457 fix: length of undefined on users warnings 2024-07-14 13:50:57 +01:00
133 changed files with 11519 additions and 8269 deletions

View File

@@ -403,42 +403,6 @@
"contributions": [ "contributions": [
"doc" "doc"
] ]
},
{
"login": "mobihen",
"name": "Nir Israel Hen",
"avatar_url": "https://avatars.githubusercontent.com/u/35529491?v=4",
"profile": "https://mobihen.com",
"contributions": [
"translation"
]
},
{
"login": "XDark187",
"name": "Baraa",
"avatar_url": "https://avatars.githubusercontent.com/u/39034192?v=4",
"profile": "https://github.com/XDark187",
"contributions": [
"code"
]
},
{
"login": "franciscofsales",
"name": "Francisco Sales",
"avatar_url": "https://avatars.githubusercontent.com/u/7977645?v=4",
"profile": "https://github.com/franciscofsales",
"contributions": [
"code"
]
},
{
"login": "myselfolli",
"name": "Oliver Laing",
"avatar_url": "https://avatars.githubusercontent.com/u/37535998?v=4",
"profile": "https://github.com/myselfolli",
"contributions": [
"code"
]
} }
] ]
} }

2
.github/CODEOWNERS vendored
View File

@@ -1,2 +1,2 @@
# Global code ownership # Global code ownership
* @Fallenbagel @gauthier-th * @Fallenbagel

View File

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

View File

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

View File

@@ -11,7 +11,7 @@
<a href="http://jellyseerr.borgcube.de/engage/jellyseerr/"><img src="http://jellyseerr.borgcube.de/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a> <a href="http://jellyseerr.borgcube.de/engage/jellyseerr/"><img src="http://jellyseerr.borgcube.de/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a> <a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-47-orange.svg"/></a> <a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-40-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
**Jellyseerr** is a free and open source software application for managing requests for your media library. **Jellyseerr** is a free and open source software application for managing requests for your media library.
@@ -137,15 +137,6 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td> <td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td> <td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td> <td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://mobihen.com"><img src="https://avatars.githubusercontent.com/u/35529491?v=4?s=100" width="100px;" alt="Nir Israel Hen"/><br /><sub><b>Nir Israel Hen</b></sub></a><br /><a href="#translation-mobihen" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/XDark187"><img src="https://avatars.githubusercontent.com/u/39034192?v=4?s=100" width="100px;" alt="Baraa"/><br /><sub><b>Baraa</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=XDark187" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/franciscofsales"><img src="https://avatars.githubusercontent.com/u/7977645?v=4?s=100" width="100px;" alt="Francisco Sales"/><br /><sub><b>Francisco Sales</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=franciscofsales" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/myselfolli"><img src="https://avatars.githubusercontent.com/u/37535998?v=4?s=100" width="100px;" alt="Oliver Laing"/><br /><sub><b>Oliver Laing</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=myselfolli" title="Code">💻</a></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -1,5 +1,5 @@
const testUser = { const testUser = {
username: 'Test User', displayName: 'Test User',
emailAddress: 'test@seeerr.dev', emailAddress: 'test@seeerr.dev',
password: 'test1234', password: 'test1234',
}; };
@@ -32,7 +32,7 @@ describe('User List', () => {
cy.get('[data-testid=modal-title]').should('contain', 'Create Local User'); cy.get('[data-testid=modal-title]').should('contain', 'Create Local User');
cy.get('#username').type(testUser.username); cy.get('#displayName').type(testUser.displayName);
cy.get('#email').type(testUser.emailAddress); cy.get('#email').type(testUser.emailAddress);
cy.get('#password').type(testUser.password); cy.get('#password').type(testUser.password);

View File

@@ -79,7 +79,7 @@ pnpm start
``` ```
</TabItem> </TabItem>
</Tabs> </Tabs>
:::info :::info
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser. You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
::: :::
@@ -99,9 +99,6 @@ PORT=5055
## Uncomment if your media server is emby instead of jellyfin. ## Uncomment if your media server is emby instead of jellyfin.
# JELLYFIN_TYPE=emby # JELLYFIN_TYPE=emby
## Uncomment if you want to force Node.js to resolve IPv4 before IPv6 (advanced users only)
# FORCE_IPV4_FIRST=true
``` ```
2. Then run the following commands: 2. Then run the following commands:
```bash ```bash
@@ -248,7 +245,6 @@ git checkout main
``` ```
3. Install the dependencies: 3. Install the dependencies:
```powershell ```powershell
npm install -g win-node-env
set CYPRESS_INSTALL_BINARY=0 && yarn install --frozen-lockfile --network-timeout 1000000 set CYPRESS_INSTALL_BINARY=0 && yarn install --frozen-lockfile --network-timeout 1000000
``` ```
4. Build the project: 4. Build the project:
@@ -273,7 +269,6 @@ git checkout develop # by default, you are on the develop branch so this step is
``` ```
3. Install the dependencies: 3. Install the dependencies:
```powershell ```powershell
npm install -g win-node-env
set CYPRESS_INSTALL_BINARY=0 && pnpm install --frozen-lockfile set CYPRESS_INSTALL_BINARY=0 && pnpm install --frozen-lockfile
``` ```
4. Build the project: 4. Build the project:
@@ -317,7 +312,7 @@ node dist/index.js
Now, Jellyseerr will start when the computer boots up in the background. Now, Jellyseerr will start when the computer boots up in the background.
</TabItem> </TabItem>
<TabItem value="nssm" label="NSSM"> <TabItem value="nssm" label="NSSM">
To run jellyseerr as a service: To run jellyseerr as a service:
1. Download the [Non-Sucking Service Manager](https://nssm.cc/download) 1. Download the [Non-Sucking Service Manager](https://nssm.cc/download)

View File

@@ -4,7 +4,6 @@
module.exports = { module.exports = {
env: { env: {
commitTag: process.env.COMMIT_TAG || 'local', commitTag: process.env.COMMIT_TAG || 'local',
forceIpv4First: process.env.FORCE_IPV4_FIRST === 'true' ? 'true' : 'false',
}, },
publicRuntimeConfig: { publicRuntimeConfig: {
// Will be available on both server and client // Will be available on both server and client
@@ -14,6 +13,7 @@ module.exports = {
remotePatterns: [ remotePatterns: [
{ hostname: 'gravatar.com' }, { hostname: 'gravatar.com' },
{ hostname: 'image.tmdb.org' }, { hostname: 'image.tmdb.org' },
{ hostname: '*', protocol: 'https' },
], ],
}, },
webpack(config) { webpack(config) {

View File

@@ -2775,15 +2775,6 @@ paths:
imageCount: imageCount:
type: number type: number
example: 123 example: 123
avatar:
type: object
properties:
size:
type: number
example: 123456
imageCount:
type: number
example: 123
apiCaches: apiCaches:
type: array type: array
items: items:

View File

@@ -43,6 +43,8 @@
"@svgr/webpack": "6.5.1", "@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.30", "@tanem/react-nprogress": "5.0.30",
"ace-builds": "1.15.2", "ace-builds": "1.15.2",
"axios": "1.3.4",
"axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0", "bcrypt": "5.1.0",
"bowser": "2.11.0", "bowser": "2.11.0",
"connect-typeorm": "1.1.4", "connect-typeorm": "1.1.4",
@@ -62,7 +64,6 @@
"formik": "^2.4.6", "formik": "^2.4.6",
"gravatar-url": "3.1.0", "gravatar-url": "3.1.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"mime": "3",
"next": "^14.2.4", "next": "^14.2.4",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"node-gyp": "9.3.1", "node-gyp": "9.3.1",
@@ -120,8 +121,7 @@
"@types/express": "4.17.17", "@types/express": "4.17.17",
"@types/express-session": "1.17.6", "@types/express-session": "1.17.6",
"@types/lodash": "4.14.191", "@types/lodash": "4.14.191",
"@types/mime": "3", "@types/node": "17.0.36",
"@types/node": "20.14.8",
"@types/node-schedule": "2.1.0", "@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7", "@types/nodemailer": "6.4.7",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",

12824
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -93,7 +93,9 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
} }
class JellyfinAPI extends ExternalAPI { class JellyfinAPI extends ExternalAPI {
private authToken?: string;
private userId?: string; private userId?: string;
private jellyfinHost: string;
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) { constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
let authHeaderVal: string; let authHeaderVal: string;
@@ -109,9 +111,14 @@ class JellyfinAPI extends ExternalAPI {
{ {
headers: { headers: {
'X-Emby-Authorization': authHeaderVal, 'X-Emby-Authorization': authHeaderVal,
'Content-Type': 'application/json',
Accept: 'application/json',
}, },
} }
); );
this.jellyfinHost = jellyfinHost;
this.authToken = authToken;
} }
public async login( public async login(
@@ -120,7 +127,7 @@ class JellyfinAPI extends ExternalAPI {
ClientIP?: string ClientIP?: string
): Promise<JellyfinLoginResponse> { ): Promise<JellyfinLoginResponse> {
const authenticate = async (useHeaders: boolean) => { const authenticate = async (useHeaders: boolean) => {
const headers: { [key: string]: string } = const headers =
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {}; useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
return this.post<JellyfinLoginResponse>( return this.post<JellyfinLoginResponse>(
@@ -129,8 +136,6 @@ class JellyfinAPI extends ExternalAPI {
Username, Username,
Pw: Password, Pw: Password,
}, },
{},
undefined,
{ headers } { headers }
); );
}; };
@@ -147,7 +152,7 @@ class JellyfinAPI extends ExternalAPI {
try { try {
return await authenticate(false); return await authenticate(false);
} catch (e) { } catch (e) {
const status = e.cause?.status; const status = e.response?.status;
const networkErrorCodes = new Set([ const networkErrorCodes = new Set([
'ECONNREFUSED', 'ECONNREFUSED',
@@ -185,7 +190,7 @@ class JellyfinAPI extends ExternalAPI {
return systemInfoResponse; return systemInfoResponse;
} catch (e) { } catch (e) {
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -202,7 +207,7 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown); throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
} }
} }
@@ -217,7 +222,7 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -233,7 +238,7 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -291,16 +296,7 @@ class JellyfinAPI extends ExternalAPI {
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> { public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
try { try {
const libraryItemsResponse = await this.get<any>( const libraryItemsResponse = await this.get<any>(
`/Users/${this.userId}/Items`, `/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
{
SortBy: 'SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Series,Movie,Others',
Recursive: 'true',
StartIndex: '0',
ParentId: id,
collapseBoxSetItems: 'false',
}
); );
return libraryItemsResponse.Items.filter( return libraryItemsResponse.Items.filter(
@@ -312,18 +308,14 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> { public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
try { try {
const itemResponse = await this.get<any>( const itemResponse = await this.get<any>(
`/Users/${this.userId}/Items/Latest`, `/Users/${this.userId}/Items/Latest?Limit=12&ParentId=${id}`
{
Limit: '12',
ParentId: id,
}
); );
return itemResponse; return itemResponse;
@@ -333,7 +325,7 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -348,7 +340,7 @@ class JellyfinAPI extends ExternalAPI {
return itemResponse; return itemResponse;
} catch (e) { } catch (e) {
if (availabilitySync.running) { if (availabilitySync.running) {
if (e.cause?.status === 500) { if (e.response && e.response.status === 500) {
return undefined; return undefined;
} }
} }
@@ -357,7 +349,7 @@ class JellyfinAPI extends ExternalAPI {
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`, `Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -372,7 +364,7 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -382,10 +374,7 @@ class JellyfinAPI extends ExternalAPI {
): Promise<JellyfinLibraryItem[]> { ): Promise<JellyfinLibraryItem[]> {
try { try {
const episodeResponse = await this.get<any>( const episodeResponse = await this.get<any>(
`/Shows/${seriesID}/Episodes`, `/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
{
seasonId: seasonID,
}
); );
return episodeResponse.Items.filter( return episodeResponse.Items.filter(
@@ -397,23 +386,6 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async createApiToken(appName: string): Promise<string> {
try {
await this.post(`/Auth/Keys?App=${appName}`);
const apiKeys = await this.get<any>(`/Auth/Keys`);
return apiKeys.Items.reverse().find(
(item: any) => item.AppName === appName
).AccessToken;
} catch (e) {
logger.error(
`Something went wrong while creating an API key the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); 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 type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
import cacheManager from '@server/lib/cache'; import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import xml2js from 'xml2js'; import xml2js from 'xml2js';
import ExternalAPI from './externalapi';
interface PlexAccountResponse { interface PlexAccountResponse {
user: PlexUser; user: PlexUser;
@@ -137,6 +137,8 @@ class PlexTvAPI extends ExternalAPI {
{ {
headers: { headers: {
'X-Plex-Token': authToken, 'X-Plex-Token': authToken,
'Content-Type': 'application/json',
Accept: 'application/json',
}, },
nodeCache: cacheManager.getCache('plextv').data, nodeCache: cacheManager.getCache('plextv').data,
} }
@@ -147,11 +149,15 @@ class PlexTvAPI extends ExternalAPI {
public async getDevices(): Promise<PlexDevice[]> { public async getDevices(): Promise<PlexDevice[]> {
try { try {
const devicesResp = await this.get('/api/resources', { const devicesResp = await this.axios.get(
includeHttps: '1', '/api/resources?includeHttps=1',
}); {
transformResponse: [],
responseType: 'text',
}
);
const parsedXml = await xml2js.parseStringPromise( const parsedXml = await xml2js.parseStringPromise(
devicesResp as DeviceResponse devicesResp.data as DeviceResponse
); );
return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({ return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({
name: pxml.$.name, name: pxml.$.name,
@@ -199,11 +205,11 @@ class PlexTvAPI extends ExternalAPI {
public async getUser(): Promise<PlexUser> { public async getUser(): Promise<PlexUser> {
try { try {
const account = await this.get<PlexAccountResponse>( const account = await this.axios.get<PlexAccountResponse>(
'/users/account.json' '/users/account.json'
); );
return account.user; return account.data.user;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the account from plex.tv: ${e.message}`, `Something went wrong while getting the account from plex.tv: ${e.message}`,
@@ -243,10 +249,13 @@ class PlexTvAPI extends ExternalAPI {
} }
public async getUsers(): Promise<UsersResponse> { public async getUsers(): Promise<UsersResponse> {
const data = await this.get('/api/users'); const response = await this.axios.get('/api/users', {
transformResponse: [],
responseType: 'text',
});
const parsedXml = (await xml2js.parseStringPromise( const parsedXml = (await xml2js.parseStringPromise(
data as string response.data
)) as UsersResponse; )) as UsersResponse;
return parsedXml; return parsedXml;
} }
@@ -261,49 +270,49 @@ class PlexTvAPI extends ExternalAPI {
items: PlexWatchlistItem[]; items: PlexWatchlistItem[];
}> { }> {
try { try {
const params = new URLSearchParams({ const response = await this.axios.get<WatchlistResponse>(
'X-Plex-Container-Start': offset.toString(), '/library/sections/watchlist/all',
'X-Plex-Container-Size': size.toString(),
});
const response = await this.fetch(
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
{ {
headers: this.defaultHeaders, params: {
'X-Plex-Container-Start': offset,
'X-Plex-Container-Size': size,
},
baseURL: 'https://metadata.provider.plex.tv',
} }
); );
const data = (await response.json()) as WatchlistResponse;
const watchlistDetails = await Promise.all( const watchlistDetails = await Promise.all(
(data.MediaContainer.Metadata ?? []).map(async (watchlistItem) => { (response.data.MediaContainer.Metadata ?? []).map(
const detailedResponse = await this.getRolling<MetadataResponse>( async (watchlistItem) => {
`/library/metadata/${watchlistItem.ratingKey}`, const detailedResponse = await this.getRolling<MetadataResponse>(
{}, `/library/metadata/${watchlistItem.ratingKey}`,
undefined, {
{}, baseURL: 'https://metadata.provider.plex.tv',
'https://metadata.provider.plex.tv' }
); );
const metadata = detailedResponse.MediaContainer.Metadata[0]; const metadata = detailedResponse.MediaContainer.Metadata[0];
const tmdbString = metadata.Guid.find((guid) => const tmdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tmdb') guid.id.startsWith('tmdb')
); );
const tvdbString = metadata.Guid.find((guid) => const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb') guid.id.startsWith('tvdb')
); );
return { return {
ratingKey: metadata.ratingKey, ratingKey: metadata.ratingKey,
// This should always be set? But I guess it also cannot be? // This should always be set? But I guess it also cannot be?
// We will filter out the 0's afterwards // We will filter out the 0's afterwards
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0, tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
tvdbId: tvdbString tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1]) ? Number(tvdbString.id.split('//')[1])
: undefined, : undefined,
title: metadata.title, title: metadata.title,
type: metadata.type, type: metadata.type,
}; };
}) }
)
); );
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId); const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
@@ -311,7 +320,7 @@ class PlexTvAPI extends ExternalAPI {
return { return {
offset, offset,
size, size,
totalSize: data.MediaContainer.totalSize, totalSize: response.data.MediaContainer.totalSize,
items: filteredList, items: filteredList,
}; };
} catch (e) { } catch (e) {

View File

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

View File

@@ -155,13 +155,13 @@ export interface IMDBRating {
*/ */
class IMDBRadarrProxy extends ExternalAPI { class IMDBRadarrProxy extends ExternalAPI {
constructor() { constructor() {
super( super('https://api.radarr.video/v1', {
'https://api.radarr.video/v1', headers: {
{}, 'Content-Type': 'application/json',
{ Accept: 'application/json',
nodeCache: cacheManager.getCache('imdb').data, },
} nodeCache: cacheManager.getCache('imdb').data,
); });
} }
/** /**
@@ -175,11 +175,7 @@ class IMDBRadarrProxy extends ExternalAPI {
`/movie/imdb/${IMDBid}` `/movie/imdb/${IMDBid}`
); );
if ( if (!data?.length || data[0].ImdbId !== IMDBid) {
!data?.length ||
data[0].ImdbId !== IMDBid ||
!data[0].MovieRatings.Imdb
) {
return null; return null;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -112,8 +112,8 @@ class TheMovieDb extends ExternalAPI {
{ {
nodeCache: cacheManager.getCache('tmdb').data, nodeCache: cacheManager.getCache('tmdb').data,
rateLimit: { rateLimit: {
maxRequests: 20,
maxRPS: 50, maxRPS: 50,
id: 'tmdb',
}, },
} }
); );
@@ -129,10 +129,7 @@ class TheMovieDb extends ExternalAPI {
}: SearchOptions): Promise<TmdbSearchMultiResponse> => { }: SearchOptions): Promise<TmdbSearchMultiResponse> => {
try { try {
const data = await this.get<TmdbSearchMultiResponse>('/search/multi', { const data = await this.get<TmdbSearchMultiResponse>('/search/multi', {
query, params: { query, page, include_adult: includeAdult, language },
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
}); });
return data; return data;
@@ -155,11 +152,13 @@ class TheMovieDb extends ExternalAPI {
}: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => { }: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => {
try { try {
const data = await this.get<TmdbSearchMovieResponse>('/search/movie', { const data = await this.get<TmdbSearchMovieResponse>('/search/movie', {
query, params: {
page: page.toString(), query,
include_adult: includeAdult ? 'true' : 'false', page,
language, include_adult: includeAdult,
primary_release_year: year?.toString() || '', language,
primary_release_year: year,
},
}); });
return data; return data;
@@ -182,11 +181,13 @@ class TheMovieDb extends ExternalAPI {
}: SingleSearchOptions): Promise<TmdbSearchTvResponse> => { }: SingleSearchOptions): Promise<TmdbSearchTvResponse> => {
try { try {
const data = await this.get<TmdbSearchTvResponse>('/search/tv', { const data = await this.get<TmdbSearchTvResponse>('/search/tv', {
query, params: {
page: page.toString(), query,
include_adult: includeAdult ? 'true' : 'false', page,
language, include_adult: includeAdult,
first_air_date_year: year?.toString() || '', language,
first_air_date_year: year,
},
}); });
return data; return data;
@@ -209,7 +210,7 @@ class TheMovieDb extends ExternalAPI {
}): Promise<TmdbPersonDetails> => { }): Promise<TmdbPersonDetails> => {
try { try {
const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, { const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, {
language, params: { language },
}); });
return data; return data;
@@ -229,7 +230,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbPersonCombinedCredits>( const data = await this.get<TmdbPersonCombinedCredits>(
`/person/${personId}/combined_credits`, `/person/${personId}/combined_credits`,
{ {
language, params: { language },
} }
); );
@@ -252,9 +253,11 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbMovieDetails>( const data = await this.get<TmdbMovieDetails>(
`/movie/${movieId}`, `/movie/${movieId}`,
{ {
language, params: {
append_to_response: language,
'credits,external_ids,videos,keywords,release_dates,watch/providers', append_to_response:
'credits,external_ids,videos,keywords,release_dates,watch/providers',
},
}, },
43200 43200
); );
@@ -276,9 +279,11 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbTvDetails>( const data = await this.get<TmdbTvDetails>(
`/tv/${tvId}`, `/tv/${tvId}`,
{ {
language, params: {
append_to_response: language,
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers', append_to_response:
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
},
}, },
43200 43200
); );
@@ -302,8 +307,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSeasonWithEpisodes>( const data = await this.get<TmdbSeasonWithEpisodes>(
`/tv/${tvId}/season/${seasonNumber}`, `/tv/${tvId}/season/${seasonNumber}`,
{ {
language: language || '', params: {
append_to_response: 'external_ids', language,
append_to_response: 'external_ids',
},
} }
); );
@@ -326,8 +333,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>( const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/recommendations`, `/movie/${movieId}/recommendations`,
{ {
page: page.toString(), params: {
language, page,
language,
},
} }
); );
@@ -350,8 +359,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>( const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/similar`, `/movie/${movieId}/similar`,
{ {
page: page.toString(), params: {
language, page,
language,
},
} }
); );
@@ -374,8 +385,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>( const data = await this.get<TmdbSearchMovieResponse>(
`/keyword/${keywordId}/movies`, `/keyword/${keywordId}/movies`,
{ {
page: page.toString(), params: {
language, page,
language,
},
} }
); );
@@ -398,8 +411,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchTvResponse>( const data = await this.get<TmdbSearchTvResponse>(
`/tv/${tvId}/recommendations`, `/tv/${tvId}/recommendations`,
{ {
page: page.toString(), params: {
language, page,
language,
},
} }
); );
@@ -422,8 +437,10 @@ class TheMovieDb extends ExternalAPI {
}): Promise<TmdbSearchTvResponse> { }): Promise<TmdbSearchTvResponse> {
try { try {
const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, { const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, {
page: page.toString(), params: {
language, page,
language,
},
}); });
return data; return data;
@@ -464,38 +481,40 @@ class TheMovieDb extends ExternalAPI {
.split('T')[0]; .split('T')[0];
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', { const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
sort_by: sortBy, params: {
page: page.toString(), sort_by: sortBy,
include_adult: includeAdult ? 'true' : 'false', page,
language, include_adult: includeAdult,
region: this.region || '', language,
with_original_language: region: this.region,
originalLanguage && originalLanguage !== 'all' with_original_language:
? originalLanguage originalLanguage && originalLanguage !== 'all'
: originalLanguage === 'all' ? originalLanguage
? '' : originalLanguage === 'all'
: this.originalLanguage || '', ? undefined
// Set our release date values, but check if one is set and not the other, : this.originalLanguage,
// so we can force a past date or a future date. TMDB Requires both values if one is set! // Set our release date values, but check if one is set and not the other,
'primary_release_date.gte': // so we can force a past date or a future date. TMDB Requires both values if one is set!
!primaryReleaseDateGte && primaryReleaseDateLte 'primary_release_date.gte':
? defaultPastDate !primaryReleaseDateGte && primaryReleaseDateLte
: primaryReleaseDateGte || '', ? defaultPastDate
'primary_release_date.lte': : primaryReleaseDateGte,
!primaryReleaseDateLte && primaryReleaseDateGte 'primary_release_date.lte':
? defaultFutureDate !primaryReleaseDateLte && primaryReleaseDateGte
: primaryReleaseDateLte || '', ? defaultFutureDate
with_genres: genre || '', : primaryReleaseDateLte,
with_companies: studio || '', with_genres: genre,
with_keywords: keywords || '', with_companies: studio,
'with_runtime.gte': withRuntimeGte || '', with_keywords: keywords,
'with_runtime.lte': withRuntimeLte || '', 'with_runtime.gte': withRuntimeGte,
'vote_average.gte': voteAverageGte || '', 'with_runtime.lte': withRuntimeLte,
'vote_average.lte': voteAverageLte || '', 'vote_average.gte': voteAverageGte,
'vote_count.gte': voteCountGte || '', 'vote_average.lte': voteAverageLte,
'vote_count.lte': voteCountLte || '', 'vote_count.gte': voteCountGte,
watch_region: watchRegion || '', 'vote_count.lte': voteCountLte,
with_watch_providers: watchProviders || '', watch_region: watchRegion,
with_watch_providers: watchProviders,
},
}); });
return data; return data;
@@ -536,40 +555,40 @@ class TheMovieDb extends ExternalAPI {
.split('T')[0]; .split('T')[0];
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', { const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
sort_by: sortBy, params: {
page: page.toString(), sort_by: sortBy,
language, page,
region: this.region || '', language,
// Set our release date values, but check if one is set and not the other, region: this.region,
// so we can force a past date or a future date. TMDB Requires both values if one is set! // Set our release date values, but check if one is set and not the other,
'first_air_date.gte': // so we can force a past date or a future date. TMDB Requires both values if one is set!
!firstAirDateGte && firstAirDateLte 'first_air_date.gte':
? defaultPastDate !firstAirDateGte && firstAirDateLte
: firstAirDateGte || '', ? defaultPastDate
'first_air_date.lte': : firstAirDateGte,
!firstAirDateLte && firstAirDateGte 'first_air_date.lte':
? defaultFutureDate !firstAirDateLte && firstAirDateGte
: firstAirDateLte || '', ? defaultFutureDate
with_original_language: : firstAirDateLte,
originalLanguage && originalLanguage !== 'all' with_original_language:
? originalLanguage originalLanguage && originalLanguage !== 'all'
: originalLanguage === 'all' ? originalLanguage
? '' : originalLanguage === 'all'
: this.originalLanguage || '', ? undefined
include_null_first_air_dates: includeEmptyReleaseDate : this.originalLanguage,
? 'true' include_null_first_air_dates: includeEmptyReleaseDate,
: 'false', with_genres: genre,
with_genres: genre || '', with_networks: network,
with_networks: network?.toString() || '', with_keywords: keywords,
with_keywords: keywords || '', 'with_runtime.gte': withRuntimeGte,
'with_runtime.gte': withRuntimeGte || '', 'with_runtime.lte': withRuntimeLte,
'with_runtime.lte': withRuntimeLte || '', 'vote_average.gte': voteAverageGte,
'vote_average.gte': voteAverageGte || '', 'vote_average.lte': voteAverageLte,
'vote_average.lte': voteAverageLte || '', 'vote_count.gte': voteCountGte,
'vote_count.gte': voteCountGte || '', 'vote_count.lte': voteCountLte,
'vote_count.lte': voteCountLte || '', with_watch_providers: watchProviders,
with_watch_providers: watchProviders || '', watch_region: watchRegion,
watch_region: watchRegion || '', },
}); });
return data; return data;
@@ -589,10 +608,12 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbUpcomingMoviesResponse>( const data = await this.get<TmdbUpcomingMoviesResponse>(
'/movie/upcoming', '/movie/upcoming',
{ {
page: page.toString(), params: {
language, page,
region: this.region || '', language,
originalLanguage: this.originalLanguage || '', region: this.region,
originalLanguage: this.originalLanguage,
},
} }
); );
@@ -615,9 +636,11 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMultiResponse>( const data = await this.get<TmdbSearchMultiResponse>(
`/trending/all/${timeWindow}`, `/trending/all/${timeWindow}`,
{ {
page: page.toString(), params: {
language, page,
region: this.region || '', language,
region: this.region,
},
} }
); );
@@ -638,7 +661,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>( const data = await this.get<TmdbSearchMovieResponse>(
`/trending/movie/${timeWindow}`, `/trending/movie/${timeWindow}`,
{ {
page: page.toString(), params: {
page,
},
} }
); );
@@ -659,7 +684,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchTvResponse>( const data = await this.get<TmdbSearchTvResponse>(
`/trending/tv/${timeWindow}`, `/trending/tv/${timeWindow}`,
{ {
page: page.toString(), params: {
page,
},
} }
); );
@@ -688,8 +715,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbExternalIdResponse>( const data = await this.get<TmdbExternalIdResponse>(
`/find/${externalId}`, `/find/${externalId}`,
{ {
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id', params: {
language, external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
language,
},
} }
); );
@@ -779,7 +808,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbCollection>( const data = await this.get<TmdbCollection>(
`/collection/${collectionId}`, `/collection/${collectionId}`,
{ {
language, params: {
language,
},
} }
); );
@@ -852,7 +883,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbGenresResult>( const data = await this.get<TmdbGenresResult>(
'/genre/movie/list', '/genre/movie/list',
{ {
language, params: {
language,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -864,7 +897,9 @@ class TheMovieDb extends ExternalAPI {
const englishData = await this.get<TmdbGenresResult>( const englishData = await this.get<TmdbGenresResult>(
'/genre/movie/list', '/genre/movie/list',
{ {
language: 'en', params: {
language: 'en',
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -899,7 +934,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbGenresResult>( const data = await this.get<TmdbGenresResult>(
'/genre/tv/list', '/genre/tv/list',
{ {
language, params: {
language,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -911,7 +948,9 @@ class TheMovieDb extends ExternalAPI {
const englishData = await this.get<TmdbGenresResult>( const englishData = await this.get<TmdbGenresResult>(
'/genre/tv/list', '/genre/tv/list',
{ {
language: 'en', params: {
language: 'en',
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -966,8 +1005,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbKeywordSearchResponse>( const data = await this.get<TmdbKeywordSearchResponse>(
'/search/keyword', '/search/keyword',
{ {
query, params: {
page: page.toString(), query,
page,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -989,8 +1030,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbCompanySearchResponse>( const data = await this.get<TmdbCompanySearchResponse>(
'/search/company', '/search/company',
{ {
query, params: {
page: page.toString(), query,
page,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -1010,7 +1053,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>( const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
'/watch/providers/regions', '/watch/providers/regions',
{ {
language: language ? this.originalLanguage || '' : '', params: {
language: language ?? this.originalLanguage,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -1034,8 +1079,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>( const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/movie', '/watch/providers/movie',
{ {
language: language ? this.originalLanguage || '' : '', params: {
watch_region: watchRegion, language: language ?? this.originalLanguage,
watch_region: watchRegion,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -1059,8 +1106,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>( const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/tv', '/watch/providers/tv',
{ {
language: language ? this.originalLanguage || '' : '', params: {
watch_region: watchRegion, language: language ?? this.originalLanguage,
watch_region: watchRegion,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );

View File

@@ -19,7 +19,6 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import clearCookies from '@server/middleware/clearcookies'; import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes'; import routes from '@server/routes';
import avatarproxy from '@server/routes/avatarproxy';
import imageproxy from '@server/routes/imageproxy'; import imageproxy from '@server/routes/imageproxy';
import { getAppVersion } from '@server/utils/appVersion'; import { getAppVersion } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag'; import restartFlag from '@server/utils/restartFlag';
@@ -33,17 +32,10 @@ import * as OpenApiValidator from 'express-openapi-validator';
import type { Store } from 'express-session'; import type { Store } from 'express-session';
import session from 'express-session'; import session from 'express-session';
import next from 'next'; import next from 'next';
import dns from 'node:dns';
import net from 'node:net';
import path from 'path'; import path from 'path';
import swaggerUi from 'swagger-ui-express'; import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs'; import YAML from 'yamljs';
if (process.env.forceIpv4First === 'true') {
dns.setDefaultResultOrder('ipv4first');
net.setDefaultAutoSelectFamily(false);
}
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
logger.info(`Starting Overseerr version ${getAppVersion()}`); logger.info(`Starting Overseerr version ${getAppVersion()}`);
@@ -64,7 +56,7 @@ app
} }
// Load Settings // Load Settings
const settings = await getSettings().load(); const settings = getSettings().load();
restartFlag.initializeSettings(settings.main); restartFlag.initializeSettings(settings.main);
// Migrate library types // Migrate library types
@@ -203,7 +195,6 @@ app
// Do not set cookies so CDNs can cache them // Do not set cookies so CDNs can cache them
server.use('/imageproxy', clearCookies, imageproxy); server.use('/imageproxy', clearCookies, imageproxy);
server.use('/avatarproxy', clearCookies, avatarproxy);
server.get('*', (req, res) => handle(req, res)); server.get('*', (req, res) => handle(req, res));
server.use( server.use(

View File

@@ -8,16 +8,3 @@ interface PageInfo {
export interface PaginatedResponse { export interface PaginatedResponse {
pageInfo: PageInfo; pageInfo: PageInfo;
} }
/**
* Get the keys of an object that are not functions
*/
type NonFunctionPropertyNames<T> = {
// eslint-disable-next-line @typescript-eslint/ban-types
[K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];
/**
* Get the properties of an object that are not functions
*/
export type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;

View File

@@ -1,9 +1,9 @@
import type { MediaType } from '@server/constants/media'; import type { MediaType } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties, PaginatedResponse } from './common'; import type { PaginatedResponse } from './common';
export interface RequestResultsResponse extends PaginatedResponse { export interface RequestResultsResponse extends PaginatedResponse {
results: NonFunctionProperties<MediaRequest>[]; results: MediaRequest[];
} }
export type MediaRequestBody = { export type MediaRequestBody = {
@@ -14,7 +14,6 @@ export type MediaRequestBody = {
is4k?: boolean; is4k?: boolean;
serverId?: number; serverId?: number;
profileId?: number; profileId?: number;
profileName?: string;
rootFolder?: string; rootFolder?: string;
languageProfileId?: number; languageProfileId?: number;
userId?: number; userId?: number;

View File

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

View File

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

View File

@@ -63,7 +63,12 @@ class AvailabilitySync {
) { ) {
admin = await userRepository.findOne({ admin = await userRepository.findOne({
where: { id: 1 }, where: { id: 1 },
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'], select: [
'id',
'jellyfinAuthToken',
'jellyfinUserId',
'jellyfinDeviceId',
],
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
} }
@@ -81,7 +86,7 @@ class AvailabilitySync {
if (admin) { if (admin) {
this.jellyfinClient = new JellyfinAPI( this.jellyfinClient = new JellyfinAPI(
getHostname(), getHostname(),
settings.jellyfin.apiKey, admin.jellyfinAuthToken,
admin.jellyfinDeviceId admin.jellyfinDeviceId
); );

View File

@@ -1,9 +1,8 @@
import logger from '@server/logger'; import logger from '@server/logger';
import type { RateLimitOptions } from '@server/utils/rateLimit'; import axios from 'axios';
import rateLimit from '@server/utils/rateLimit'; import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { promises } from 'fs'; import { promises } from 'fs';
import mime from 'mime/lite';
import path, { join } from 'path'; import path, { join } from 'path';
type ImageResponse = { type ImageResponse = {
@@ -12,7 +11,7 @@ type ImageResponse = {
curRevalidate: number; curRevalidate: number;
isStale: boolean; isStale: boolean;
etag: string; etag: string;
extension: string | null; extension: string;
cacheKey: string; cacheKey: string;
cacheMiss: boolean; cacheMiss: boolean;
}; };
@@ -28,45 +27,29 @@ class ImageProxy {
let deletedImages = 0; let deletedImages = 0;
const cacheDirectory = path.join(baseCacheDirectory, key); const cacheDirectory = path.join(baseCacheDirectory, key);
try { const files = await promises.readdir(cacheDirectory);
const files = await promises.readdir(cacheDirectory);
for (const file of files) { for (const file of files) {
const filePath = path.join(cacheDirectory, file); const filePath = path.join(cacheDirectory, file);
const stat = await promises.lstat(filePath); const stat = await promises.lstat(filePath);
if (stat.isDirectory()) { if (stat.isDirectory()) {
const imageFiles = await promises.readdir(filePath); const imageFiles = await promises.readdir(filePath);
for (const imageFile of imageFiles) { for (const imageFile of imageFiles) {
const [, expireAtSt] = imageFile.split('.'); const [, expireAtSt] = imageFile.split('.');
const expireAt = Number(expireAtSt); const expireAt = Number(expireAtSt);
const now = Date.now(); const now = Date.now();
if (now > expireAt) { if (now > expireAt) {
await promises.rm(path.join(filePath), { await promises.rm(path.join(filePath, imageFile));
recursive: true, deletedImages += 1;
});
deletedImages += 1;
}
} }
} }
} }
} catch (e) {
if (e.code === 'ENOENT') {
logger.error('Directory not found', {
label: 'Image Cache',
message: e.message,
});
} else {
logger.error('Failed to read directory', {
label: 'Image Cache',
message: e.message,
});
}
} }
logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, { logger.info(`Cleared ${deletedImages} stale image(s) from cache`, {
label: 'Image Cache', label: 'Image Cache',
}); });
} }
@@ -86,74 +69,55 @@ class ImageProxy {
} }
private static async getDirectorySize(dir: string): Promise<number> { private static async getDirectorySize(dir: string): Promise<number> {
try { const files = await promises.readdir(dir, {
const files = await promises.readdir(dir, { withFileTypes: true,
withFileTypes: true, });
});
const paths = files.map(async (file) => { const paths = files.map(async (file) => {
const path = join(dir, file.name); const path = join(dir, file.name);
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path); if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
if (file.isFile()) { if (file.isFile()) {
const { size } = await promises.stat(path); const { size } = await promises.stat(path);
return size; return size;
}
return 0;
});
return (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
} }
}
return 0; return 0;
});
return (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
} }
private static async getImageCount(dir: string) { private static async getImageCount(dir: string) {
try { const files = await promises.readdir(dir);
const files = await promises.readdir(dir);
return files.length; return files.length;
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
}
}
return 0;
} }
private fetch: typeof fetch; private axios;
private cacheVersion; private cacheVersion;
private key; private key;
private baseUrl;
constructor( constructor(
key: string, key: string,
baseUrl: string, baseUrl: string,
options: { options: {
cacheVersion?: number; cacheVersion?: number;
rateLimitOptions?: RateLimitOptions; rateLimitOptions?: rateLimitOptions;
} = {} } = {}
) { ) {
this.cacheVersion = options.cacheVersion ?? 1; this.cacheVersion = options.cacheVersion ?? 1;
this.baseUrl = baseUrl;
this.key = key; this.key = key;
this.axios = axios.create({
baseURL: baseUrl,
});
if (options.rateLimitOptions) { if (options.rateLimitOptions) {
this.fetch = rateLimit(fetch, { this.axios = rateLimit(this.axios, options.rateLimitOptions);
...options.rateLimitOptions,
});
} else {
this.fetch = fetch;
} }
} }
@@ -180,27 +144,6 @@ class ImageProxy {
return imageResponse; return imageResponse;
} }
public async clearCachedImage(path: string) {
// find cacheKey
const cacheKey = this.getCacheKey(path);
try {
const directory = join(this.getCacheDirectory(), cacheKey);
const files = await promises.readdir(directory);
await promises.rm(directory, { recursive: true });
logger.info(`Cleared ${files[0]} image from cache 'avatar'`, {
label: 'Image Cache',
});
} catch (e) {
logger.error('Failed to clear cached image', {
label: 'Image Cache',
message: e.message,
});
}
}
private async get(cacheKey: string): Promise<ImageResponse | null> { private async get(cacheKey: string): Promise<ImageResponse | null> {
try { try {
const directory = join(this.getCacheDirectory(), cacheKey); const directory = join(this.getCacheDirectory(), cacheKey);
@@ -239,29 +182,17 @@ class ImageProxy {
): Promise<ImageResponse | null> { ): Promise<ImageResponse | null> {
try { try {
const directory = join(this.getCacheDirectory(), cacheKey); const directory = join(this.getCacheDirectory(), cacheKey);
const href = const response = await this.axios.get(path, {
this.baseUrl + responseType: 'arraybuffer',
(this.baseUrl.length > 0 });
? this.baseUrl.endsWith('/')
? ''
: '/'
: '') +
(path.startsWith('/') ? path.slice(1) : path);
const response = await this.fetch(href);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extension = mime.getExtension( const buffer = Buffer.from(response.data, 'binary');
response.headers.get('content-type') ?? '' const extension = path.split('.').pop() ?? '';
const maxAge = Number(
(response.headers['cache-control'] ?? '0').split('=')[1]
); );
let maxAge = Number(
(response.headers.get('cache-control') ?? '0').split('=')[1]
);
if (!maxAge) maxAge = 86400;
const expireAt = Date.now() + maxAge * 1000; const expireAt = Date.now() + maxAge * 1000;
const etag = (response.headers.get('etag') ?? '').replace(/"/g, ''); const etag = (response.headers.etag ?? '').replace(/"/g, '');
await this.writeToCacheDir( await this.writeToCacheDir(
directory, directory,
@@ -295,7 +226,7 @@ class ImageProxy {
private async writeToCacheDir( private async writeToCacheDir(
dir: string, dir: string,
extension: string | null, extension: string,
maxAge: number, maxAge: number,
expireAt: number, expireAt: number,
buffer: Buffer, buffer: Buffer,

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { MediaStatus } from '@server/constants/media';
import type { NotificationAgentLunaSea } from '@server/lib/settings'; import type { NotificationAgentLunaSea } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent'; import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent'; import { BaseAgent } from './agent';
@@ -100,39 +101,28 @@ class LunaSeaAgent
}); });
try { try {
const response = await fetch(settings.options.webhookUrl, { await axios.post(
method: 'POST', settings.options.webhookUrl,
headers: settings.options.profileName this.buildPayload(type, payload),
settings.options.profileName
? { ? {
'Content-Type': 'application/json', headers: {
Authorization: `Basic ${Buffer.from(
`${settings.options.profileName}:`
).toString('base64')}`,
},
} }
: { : undefined
'Content-Type': 'application/json', );
Authorization: `Basic ${Buffer.from(
`${settings.options.profileName}:`
).toString('base64')}`,
},
body: JSON.stringify(this.buildPayload(type, payload)),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
return true; return true;
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending LunaSea notification', { logger.error('Error sending LunaSea notification', {
label: 'Notifications', label: 'Notifications',
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: errorData, response: e.response?.data,
}); });
return false; return false;

View File

@@ -5,6 +5,7 @@ import { User } from '@server/entity/User';
import type { NotificationAgentPushbullet } from '@server/lib/settings'; import type { NotificationAgentPushbullet } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings'; import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { import {
hasNotificationType, hasNotificationType,
Notification, Notification,
@@ -122,34 +123,22 @@ class PushbulletAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(
method: 'POST', endpoint,
headers: { { ...notificationPayload, channel_tag: settings.options.channelTag },
'Content-Type': 'application/json', {
'Access-Token': settings.options.accessToken, headers: {
}, 'Access-Token': settings.options.accessToken,
body: JSON.stringify({ },
...notificationPayload, }
channel_tag: settings.options.channelTag, );
}),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushbullet notification', { logger.error('Error sending Pushbullet notification', {
label: 'Notifications', label: 'Notifications',
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: errorData, response: e.response?.data,
}); });
return false; return false;
@@ -174,32 +163,19 @@ class PushbulletAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(endpoint, notificationPayload, {
method: 'POST',
headers: { headers: {
'Content-Type': 'application/json',
'Access-Token': payload.notifyUser.settings.pushbulletAccessToken, 'Access-Token': payload.notifyUser.settings.pushbulletAccessToken,
}, },
body: JSON.stringify(notificationPayload),
}); });
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushbullet notification', { logger.error('Error sending Pushbullet notification', {
label: 'Notifications', label: 'Notifications',
recipient: payload.notifyUser.displayName, recipient: payload.notifyUser.displayName,
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: errorData, response: e.response?.data,
}); });
return false; return false;
@@ -235,32 +211,19 @@ class PushbulletAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(endpoint, notificationPayload, {
method: 'POST',
headers: { headers: {
'Content-Type': 'application/json',
'Access-Token': user.settings.pushbulletAccessToken, 'Access-Token': user.settings.pushbulletAccessToken,
}, },
body: JSON.stringify(notificationPayload),
}); });
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushbullet notification', { logger.error('Error sending Pushbullet notification', {
label: 'Notifications', label: 'Notifications',
recipient: user.displayName, recipient: user.displayName,
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: errorData, response: e.response?.data,
}); });
return false; return false;

View File

@@ -5,6 +5,7 @@ import { User } from '@server/entity/User';
import type { NotificationAgentPushover } from '@server/lib/settings'; import type { NotificationAgentPushover } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings'; import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { import {
hasNotificationType, hasNotificationType,
Notification, Notification,
@@ -51,15 +52,12 @@ class PushoverAgent
imageUrl: string imageUrl: string
): Promise<Partial<PushoverImagePayload>> { ): Promise<Partial<PushoverImagePayload>> {
try { try {
const response = await fetch(imageUrl); const response = await axios.get(imageUrl, {
if (!response.ok) { responseType: 'arraybuffer',
throw new Error(response.statusText, { cause: response }); });
} const base64 = Buffer.from(response.data, 'binary').toString('base64');
const arrayBuffer = await response.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString('base64');
const contentType = ( const contentType = (
response.headers.get('Content-Type') || response.headers['Content-Type'] || response.headers['content-type']
response.headers.get('content-type')
)?.toString(); )?.toString();
return { return {
@@ -67,17 +65,10 @@ class PushoverAgent
attachment_type: contentType, attachment_type: contentType,
}; };
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error getting image payload', { logger.error('Error getting image payload', {
label: 'Notifications', label: 'Notifications',
errorMessage: e.message, errorMessage: e.message,
response: errorData, response: e.response?.data,
}); });
return {}; return {};
} }
@@ -210,35 +201,19 @@ class PushoverAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(endpoint, {
method: 'POST', ...notificationPayload,
headers: { token: settings.options.accessToken,
'Content-Type': 'application/json', user: settings.options.userToken,
}, sound: settings.options.sound,
body: JSON.stringify({ } as PushoverPayload);
...notificationPayload,
token: settings.options.accessToken,
user: settings.options.userToken,
sound: settings.options.sound,
} as PushoverPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushover notification', { logger.error('Error sending Pushover notification', {
label: 'Notifications', label: 'Notifications',
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: errorData, response: e.response?.data,
}); });
return false; return false;
@@ -266,36 +241,20 @@ class PushoverAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(endpoint, {
method: 'POST', ...notificationPayload,
headers: { token: payload.notifyUser.settings.pushoverApplicationToken,
'Content-Type': 'application/json', user: payload.notifyUser.settings.pushoverUserKey,
}, sound: payload.notifyUser.settings.pushoverSound,
body: JSON.stringify({ } as PushoverPayload);
...notificationPayload,
token: payload.notifyUser.settings.pushoverApplicationToken,
user: payload.notifyUser.settings.pushoverUserKey,
sound: payload.notifyUser.settings.pushoverSound,
} as PushoverPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushover notification', { logger.error('Error sending Pushover notification', {
label: 'Notifications', label: 'Notifications',
recipient: payload.notifyUser.displayName, recipient: payload.notifyUser.displayName,
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: errorData, response: e.response?.data,
}); });
return false; return false;
@@ -332,35 +291,19 @@ class PushoverAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(endpoint, {
method: 'POST', ...notificationPayload,
headers: { token: user.settings.pushoverApplicationToken,
'Content-Type': 'application/json', user: user.settings.pushoverUserKey,
}, } as PushoverPayload);
body: JSON.stringify({
...notificationPayload,
token: user.settings.pushoverApplicationToken,
user: user.settings.pushoverUserKey,
} as PushoverPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushover notification', { logger.error('Error sending Pushover notification', {
label: 'Notifications', label: 'Notifications',
recipient: user.displayName, recipient: user.displayName,
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: errorData, response: e.response?.data,
}); });
return false; return false;

View File

@@ -2,6 +2,7 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import type { NotificationAgentSlack } from '@server/lib/settings'; import type { NotificationAgentSlack } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent'; import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent'; import { BaseAgent } from './agent';
@@ -237,32 +238,19 @@ class SlackAgent
subject: payload.subject, subject: payload.subject,
}); });
try { try {
const response = await fetch(settings.options.webhookUrl, { await axios.post(
method: 'POST', settings.options.webhookUrl,
headers: { this.buildEmbed(type, payload)
'Content-Type': 'application/json', );
},
body: JSON.stringify(this.buildEmbed(type, payload)),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
return true; return true;
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Slack notification', { logger.error('Error sending Slack notification', {
label: 'Notifications', label: 'Notifications',
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: errorData, response: e.response?.data,
}); });
return false; return false;

View File

@@ -5,6 +5,7 @@ import { User } from '@server/entity/User';
import type { NotificationAgentTelegram } from '@server/lib/settings'; import type { NotificationAgentTelegram } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings'; import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { import {
hasNotificationType, hasNotificationType,
Notification, Notification,
@@ -174,34 +175,18 @@ class TelegramAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(endpoint, {
method: 'POST', ...notificationPayload,
headers: { chat_id: settings.options.chatId,
'Content-Type': 'application/json', disable_notification: !!settings.options.sendSilently,
}, } as TelegramMessagePayload | TelegramPhotoPayload);
body: JSON.stringify({
...notificationPayload,
chat_id: settings.options.chatId,
disable_notification: !!settings.options.sendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Telegram notification', { logger.error('Error sending Telegram notification', {
label: 'Notifications', label: 'Notifications',
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: errorData, response: e.response?.data,
}); });
return false; return false;
@@ -225,36 +210,20 @@ class TelegramAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(endpoint, {
method: 'POST', ...notificationPayload,
headers: { chat_id: payload.notifyUser.settings.telegramChatId,
'Content-Type': 'application/json', disable_notification:
}, !!payload.notifyUser.settings.telegramSendSilently,
body: JSON.stringify({ } as TelegramMessagePayload | TelegramPhotoPayload);
...notificationPayload,
chat_id: payload.notifyUser.settings.telegramChatId,
disable_notification:
!!payload.notifyUser.settings.telegramSendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Telegram notification', { logger.error('Error sending Telegram notification', {
label: 'Notifications', label: 'Notifications',
recipient: payload.notifyUser.displayName, recipient: payload.notifyUser.displayName,
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: errorData, response: e.response?.data,
}); });
return false; return false;
@@ -288,35 +257,19 @@ class TelegramAgent
}); });
try { try {
const response = await fetch(endpoint, { await axios.post(endpoint, {
method: 'POST', ...notificationPayload,
headers: { chat_id: user.settings.telegramChatId,
'Content-Type': 'application/json', disable_notification: !!user.settings?.telegramSendSilently,
}, } as TelegramMessagePayload | TelegramPhotoPayload);
body: JSON.stringify({
...notificationPayload,
chat_id: user.settings.telegramChatId,
disable_notification: !!user.settings?.telegramSendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Telegram notification', { logger.error('Error sending Telegram notification', {
label: 'Notifications', label: 'Notifications',
recipient: user.displayName, recipient: user.displayName,
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: errorData, response: e.response?.data,
}); });
return false; return false;

View File

@@ -3,6 +3,7 @@ import { MediaStatus } from '@server/constants/media';
import type { NotificationAgentWebhook } from '@server/lib/settings'; import type { NotificationAgentWebhook } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { get } from 'lodash'; import { get } from 'lodash';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent'; import type { NotificationAgent, NotificationPayload } from './agent';
@@ -177,35 +178,26 @@ class WebhookAgent
}); });
try { try {
const response = await fetch(settings.options.webhookUrl, { await axios.post(
method: 'POST', settings.options.webhookUrl,
headers: { this.buildPayload(type, payload),
'Content-Type': 'application/json', settings.options.authHeader
...(settings.options.authHeader ? {
? { Authorization: settings.options.authHeader } headers: {
: {}), Authorization: settings.options.authHeader,
}, },
body: JSON.stringify(this.buildPayload(type, payload)), }
}); : undefined
if (!response.ok) { );
throw new Error(response.statusText, { cause: response });
}
return true; return true;
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending webhook notification', { logger.error('Error sending webhook notification', {
label: 'Notifications', label: 'Notifications',
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: errorData, response: e.response?.data,
}); });
return false; return false;

View File

@@ -582,7 +582,12 @@ class JellyfinScanner {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOne({ const admin = await userRepository.findOne({
where: { id: 1 }, where: { id: 1 },
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'], select: [
'id',
'jellyfinAuthToken',
'jellyfinUserId',
'jellyfinDeviceId',
],
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
@@ -590,9 +595,11 @@ class JellyfinScanner {
return this.log('No admin configured. Jellyfin sync skipped.', 'warn'); return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
} }
const hostname = getHostname();
this.jfClient = new JellyfinAPI( this.jfClient = new JellyfinAPI(
getHostname(), hostname,
settings.jellyfin.apiKey, admin.jellyfinAuthToken,
admin.jellyfinDeviceId admin.jellyfinDeviceId
); );

View File

@@ -47,7 +47,6 @@ export interface JellyfinSettings {
jellyfinForgotPasswordUrl?: string; jellyfinForgotPasswordUrl?: string;
libraries: Library[]; libraries: Library[];
serverId: string; serverId: string;
apiKey: string;
} }
export interface TautulliSettings { export interface TautulliSettings {
hostname?: string; hostname?: string;
@@ -343,7 +342,6 @@ class Settings {
jellyfinForgotPasswordUrl: '', jellyfinForgotPasswordUrl: '',
libraries: [], libraries: [],
serverId: '', serverId: '',
apiKey: '',
}, },
tautulli: {}, tautulli: {},
radarr: [], radarr: [],
@@ -631,7 +629,7 @@ class Settings {
* @param overrideSettings If passed in, will override all existing settings with these * @param overrideSettings If passed in, will override all existing settings with these
* values * values
*/ */
public async load(overrideSettings?: AllSettings): Promise<Settings> { public load(overrideSettings?: AllSettings): Settings {
if (overrideSettings) { if (overrideSettings) {
this.data = overrideSettings; this.data = overrideSettings;
return this; return this;
@@ -644,7 +642,7 @@ class Settings {
if (data) { if (data) {
const parsedJson = JSON.parse(data); const parsedJson = JSON.parse(data);
this.data = await runMigrations(parsedJson); this.data = runMigrations(parsedJson);
this.data = merge(this.data, parsedJson); this.data = merge(this.data, parsedJson);

View File

@@ -1,36 +0,0 @@
import JellyfinAPI from '@server/api/jellyfin';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { AllSettings } from '@server/lib/settings';
import { getHostname } from '@server/utils/getHostname';
const migrateApiTokens = async (settings: any): Promise<AllSettings> => {
const mediaServerType = settings.main.mediaServerType;
if (
!settings.jellyfin.apiKey &&
(mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY)
) {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
where: { id: 1 },
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' },
});
if (!admin) {
return settings;
}
const jellyfinClient = new JellyfinAPI(
getHostname(settings.jellyfin),
admin.jellyfinAuthToken,
admin.jellyfinDeviceId
);
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
settings.jellyfin.apiKey = apiKey;
}
return settings;
};
export default migrateApiTokens;

View File

@@ -1,13 +1,10 @@
import type { AllSettings } from '@server/lib/settings'; import type { AllSettings } from '@server/lib/settings';
import logger from '@server/logger';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
const migrationsDir = path.join(__dirname, 'migrations'); const migrationsDir = path.join(__dirname, 'migrations');
export const runMigrations = async ( export const runMigrations = (settings: AllSettings): AllSettings => {
settings: AllSettings
): Promise<AllSettings> => {
const migrations = fs const migrations = fs
.readdirSync(migrationsDir) .readdirSync(migrationsDir)
.filter((file) => file.endsWith('.js') || file.endsWith('.ts')) .filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
@@ -16,15 +13,8 @@ export const runMigrations = async (
let migrated = settings; let migrated = settings;
try { for (const migration of migrations) {
for (const migration of migrations) { migrated = migration(migrated);
migrated = await migration(migrated);
}
} catch (e) {
logger.error(
`Something went wrong while running settings migrations: ${e.message}`,
{ label: 'Settings Migrator' }
);
} }
return migrated; return migrated;

View File

@@ -85,7 +85,6 @@ export interface MovieDetails {
mediaUrl?: string; mediaUrl?: string;
watchProviders?: WatchProviders[]; watchProviders?: WatchProviders[];
keywords: Keyword[]; keywords: Keyword[];
onUserWatchlist?: boolean;
} }
export const mapProductionCompany = ( export const mapProductionCompany = (
@@ -102,8 +101,7 @@ export const mapProductionCompany = (
export const mapMovieDetails = ( export const mapMovieDetails = (
movie: TmdbMovieDetails, movie: TmdbMovieDetails,
media?: Media, media?: Media
userWatchlist?: boolean
): MovieDetails => ({ ): MovieDetails => ({
id: movie.id, id: movie.id,
adult: movie.adult, adult: movie.adult,
@@ -150,5 +148,4 @@ export const mapMovieDetails = (
id: keyword.id, id: keyword.id,
name: keyword.name, name: keyword.name,
})), })),
onUserWatchlist: userWatchlist,
}); });

View File

@@ -111,7 +111,6 @@ export interface TvDetails {
keywords: Keyword[]; keywords: Keyword[];
mediaInfo?: Media; mediaInfo?: Media;
watchProviders?: WatchProviders[]; watchProviders?: WatchProviders[];
onUserWatchlist?: boolean;
} }
const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({ const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
@@ -162,8 +161,7 @@ export const mapNetwork = (network: TmdbNetwork): TvNetwork => ({
export const mapTvDetails = ( export const mapTvDetails = (
show: TmdbTvDetails, show: TmdbTvDetails,
media?: Media, media?: Media
userWatchlist?: boolean
): TvDetails => ({ ): TvDetails => ({
createdBy: show.created_by, createdBy: show.created_by,
episodeRunTime: show.episode_run_time, episodeRunTime: show.episode_run_time,
@@ -225,5 +223,4 @@ export const mapTvDetails = (
})), })),
mediaInfo: media, mediaInfo: media,
watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}), watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}),
onUserWatchlist: userWatchlist,
}); });

View File

@@ -6,7 +6,6 @@ import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User'; import { User } from '@server/entity/User';
import { startJobs } from '@server/job/schedule'; import { startJobs } from '@server/job/schedule';
import ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
@@ -321,28 +320,18 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
// with admin permission // with admin permission
settings.main.mediaServerType = MediaServerType.JELLYFIN; settings.main.mediaServerType = MediaServerType.JELLYFIN;
user = new User({ user = new User({
email: body.email || account.User.Name, email: body.email,
jellyfinUsername: account.User.Name, jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id, jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId, jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN, permissions: Permission.ADMIN,
avatar: account.User.PrimaryImageTag avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email || account.User.Name, { : gravatarUrl(body.email ?? '', { default: 'mm', size: 200 }),
default: 'mm',
size: 200,
}),
userType: UserType.JELLYFIN, userType: UserType.JELLYFIN,
}); });
// Create an API key on Jellyfin from this admin user
const jellyfinClient = new JellyfinAPI(
hostname,
account.AccessToken,
deviceId
);
const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
const serverName = await jellyfinserver.getServerName(); const serverName = await jellyfinserver.getServerName();
settings.jellyfin.name = serverName; settings.jellyfin.name = serverName;
@@ -351,7 +340,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
settings.jellyfin.port = body.port ?? 8096; settings.jellyfin.port = body.port ?? 8096;
settings.jellyfin.urlBase = body.urlBase ?? ''; settings.jellyfin.urlBase = body.urlBase ?? '';
settings.jellyfin.useSsl = body.useSsl ?? false; settings.jellyfin.useSsl = body.useSsl ?? false;
settings.jellyfin.apiKey = apiKey;
settings.save(); settings.save();
startJobs(); startJobs();
@@ -375,25 +363,18 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUsername: account.User.Name, jellyfinUsername: account.User.Name,
} }
); );
// Let's check if their authtoken is up to date
if (user.jellyfinAuthToken !== account.AccessToken) {
user.jellyfinAuthToken = account.AccessToken;
}
// Update the users avatar with their jellyfin profile pic (incase it changed) // Update the users avatar with their jellyfin profile pic (incase it changed)
if (account.User.PrimaryImageTag) { if (account.User.PrimaryImageTag) {
const avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
if (avatar !== user.avatar) {
const avatarProxy = new ImageProxy('avatar', '');
avatarProxy.clearCachedImage(user.avatar);
}
user.avatar = avatar;
} else { } else {
const avatar = gravatarUrl(user.email || account.User.Name, { user.avatar = gravatarUrl(user.email, {
default: 'mm', default: 'mm',
size: 200, size: 200,
}); });
if (avatar !== user.avatar) {
const avatarProxy = new ImageProxy('avatar', '');
avatarProxy.clearCachedImage(user.avatar);
}
user.avatar = avatar;
} }
user.jellyfinUsername = account.User.Name; user.jellyfinUsername = account.User.Name;
@@ -432,18 +413,20 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
} }
); );
if (!body.email) {
throw new Error('add_email');
}
user = new User({ user = new User({
email: body.email, email: body.email,
jellyfinUsername: account.User.Name, jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id, jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId, jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: settings.main.defaultPermissions, permissions: settings.main.defaultPermissions,
avatar: account.User.PrimaryImageTag avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email || account.User.Name, { : gravatarUrl(body.email, { default: 'mm', size: 200 }),
default: 'mm',
size: 200,
}),
userType: UserType.JELLYFIN, userType: UserType.JELLYFIN,
}); });
//initialize Jellyfin/Emby users with local login //initialize Jellyfin/Emby users with local login
@@ -747,7 +730,6 @@ authRoutes.post('/reset-password/:guid', async (req, res, next) => {
}); });
} }
user.recoveryLinkExpirationDate = null; user.recoveryLinkExpirationDate = null;
await user.setPassword(req.body.password);
userRepository.save(user); userRepository.save(user);
logger.info('Successfully reset password', { logger.info('Successfully reset password', {
label: 'API', label: 'API',

View File

@@ -1,31 +0,0 @@
import ImageProxy from '@server/lib/imageproxy';
import logger from '@server/logger';
import { Router } from 'express';
const router = Router();
const avatarImageProxy = new ImageProxy('avatar', '');
// Proxy avatar images
router.get('/*', async (req, res) => {
const imagePath = req.url.startsWith('/') ? req.url.slice(1) : req.url;
try {
const imageData = await avatarImageProxy.getImage(imagePath);
res.writeHead(200, {
'Content-Type': `image/${imageData.meta.extension}`,
'Content-Length': imageData.imageBuffer.length,
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
'OS-Cache-Key': imageData.meta.cacheKey,
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
});
res.end(imageData.imageBuffer);
} catch (e) {
logger.error('Failed to proxy avatar image', {
imagePath,
errorMessage: e.message,
});
}
});
export default router;

View File

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

View File

@@ -3,9 +3,7 @@ import RottenTomatoes from '@server/api/rating/rottentomatoes';
import { type RatingResponse } from '@server/api/ratings'; import { type RatingResponse } from '@server/api/ratings';
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import { Watchlist } from '@server/entity/Watchlist';
import logger from '@server/logger'; import logger from '@server/logger';
import { mapMovieDetails } from '@server/models/Movie'; import { mapMovieDetails } from '@server/models/Movie';
import { mapMovieResult } from '@server/models/Search'; import { mapMovieResult } from '@server/models/Search';
@@ -24,24 +22,7 @@ movieRoutes.get('/:id', async (req, res, next) => {
const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE); const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE);
const onUserWatchlist = await getRepository(Watchlist).exist({ return res.status(200).json(mapMovieDetails(tmdbMovie, media));
where: {
tmdbId: Number(req.params.id),
requestedBy: {
id: req.user?.id,
},
},
});
const data = mapMovieDetails(tmdbMovie, media, onUserWatchlist);
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
if (!data.overview) {
const tvEnglish = await tmdb.getMovie({ movieId: Number(req.params.id) });
data.overview = tvEnglish.overview;
}
return res.status(200).json(data);
} catch (e) { } catch (e) {
logger.debug('Something went wrong retrieving movie', { logger.debug('Something went wrong retrieving movie', {
label: 'API', label: 'API',

View File

@@ -1,5 +1,3 @@
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import { import {
MediaRequestStatus, MediaRequestStatus,
MediaStatus, MediaStatus,
@@ -21,7 +19,6 @@ import type {
RequestResultsResponse, RequestResultsResponse,
} from '@server/interfaces/api/requestInterfaces'; } from '@server/interfaces/api/requestInterfaces';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express'; import { Router } from 'express';
@@ -146,62 +143,6 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
.skip(skip) .skip(skip)
.getManyAndCount(); .getManyAndCount();
const settings = getSettings();
// get all quality profiles for every configured sonarr server
const sonarrServers = await Promise.all(
settings.sonarr.map(async (sonarrSetting) => {
const sonarr = new SonarrAPI({
apiKey: sonarrSetting.apiKey,
url: SonarrAPI.buildUrl(sonarrSetting, '/api/v3'),
});
return {
id: sonarrSetting.id,
profiles: await sonarr.getProfiles(),
};
})
);
// get all quality profiles for every configured radarr server
const radarrServers = await Promise.all(
settings.radarr.map(async (radarrSetting) => {
const radarr = new RadarrAPI({
apiKey: radarrSetting.apiKey,
url: RadarrAPI.buildUrl(radarrSetting, '/api/v3'),
});
return {
id: radarrSetting.id,
profiles: await radarr.getProfiles(),
};
})
);
// add profile names to the media requests, with undefined if not found
const requestsWithProfileNames = requests.map((r) => {
switch (r.type) {
case MediaType.MOVIE: {
const profileName = radarrServers
.find((serverr) => serverr.id === r.serverId)
?.profiles.find((profile) => profile.id === r.profileId)?.name;
return {
...r,
profileName,
};
}
case MediaType.TV: {
return {
...r,
profileName: sonarrServers
.find((serverr) => serverr.id === r.serverId)
?.profiles.find((profile) => profile.id === r.profileId)?.name,
};
}
}
});
return res.status(200).json({ return res.status(200).json({
pageInfo: { pageInfo: {
pages: Math.ceil(requestCount / pageSize), pages: Math.ceil(requestCount / pageSize),
@@ -209,7 +150,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
results: requestCount, results: requestCount,
page: Math.ceil(skip / pageSize) + 1, page: Math.ceil(skip / pageSize) + 1,
}, },
results: requestsWithProfileNames, results: requests,
}); });
} catch (e) { } catch (e) {
next({ status: 500, message: e.message }); next({ status: 500, message: e.message });

View File

@@ -262,7 +262,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
try { try {
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
where: { id: 1 }, where: { id: 1 },
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'], select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
@@ -270,7 +270,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
getHostname(tempJellyfinSettings), getHostname(tempJellyfinSettings),
tempJellyfinSettings.apiKey, admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -318,13 +318,13 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
if (req.query.sync) { if (req.query.sync) {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'], select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
where: { id: 1 }, where: { id: 1 },
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
getHostname(), getHostname(),
settings.jellyfin.apiKey, admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -376,8 +376,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
}); });
settingsRoutes.get('/jellyfin/users', async (req, res) => { settingsRoutes.get('/jellyfin/users', async (req, res) => {
const settings = getSettings(); const { externalHostname } = getSettings().jellyfin;
const { externalHostname } = settings.jellyfin;
const jellyfinHost = const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
@@ -385,13 +384,12 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'], select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
where: { id: 1 }, where: { id: 1 },
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
getHostname(), admin.jellyfinAuthToken ?? '',
settings.jellyfin.apiKey,
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -746,13 +744,11 @@ settingsRoutes.get('/cache', async (_req, res) => {
})); }));
const tmdbImageCache = await ImageProxy.getImageStats('tmdb'); const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
const avatarImageCache = await ImageProxy.getImageStats('avatar');
return res.status(200).json({ return res.status(200).json({
apiCaches, apiCaches,
imageCache: { imageCache: {
tmdb: tmdbImageCache, tmdb: tmdbImageCache,
avatar: avatarImageCache,
}, },
}); });
}); });

View File

@@ -1,9 +1,7 @@
import RottenTomatoes from '@server/api/rating/rottentomatoes'; import RottenTomatoes from '@server/api/rating/rottentomatoes';
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import { Watchlist } from '@server/entity/Watchlist';
import logger from '@server/logger'; import logger from '@server/logger';
import { mapTvResult } from '@server/models/Search'; import { mapTvResult } from '@server/models/Search';
import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv'; import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv';
@@ -21,24 +19,7 @@ tvRoutes.get('/:id', async (req, res, next) => {
const media = await Media.getMedia(tv.id, MediaType.TV); const media = await Media.getMedia(tv.id, MediaType.TV);
const onUserWatchlist = await getRepository(Watchlist).exist({ return res.status(200).json(mapTvDetails(tv, media));
where: {
tmdbId: Number(req.params.id),
requestedBy: {
id: req.user?.id,
},
},
});
const data = mapTvDetails(tv, media, onUserWatchlist);
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
if (!data.overview) {
const tvEnglish = await tmdb.getTvShow({ tvId: Number(req.params.id) });
data.overview = tvEnglish.overview;
}
return res.status(200).json(data);
} catch (e) { } catch (e) {
logger.debug('Something went wrong retrieving series', { logger.debug('Something went wrong retrieving series', {
label: 'API', label: 'API',

View File

@@ -41,19 +41,7 @@ router.get('/', async (req, res, next) => {
break; break;
case 'displayname': case 'displayname':
query = query.orderBy( query = query.orderBy(
`CASE WHEN (user.username IS NULL OR user.username = '') THEN ( "(CASE WHEN (user.username IS NULL OR user.username = '') THEN (CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN user.email ELSE LOWER(user.plexUsername) END) ELSE LOWER(user.username) END)",
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
user.email
ELSE
LOWER(user.jellyfinUsername)
END)
ELSE
LOWER(user.jellyfinUsername)
END)
ELSE
LOWER(user.username)
END`,
'ASC' 'ASC'
); );
break; break;
@@ -102,13 +90,12 @@ router.post(
const settings = getSettings(); const settings = getSettings();
const body = req.body; const body = req.body;
const email = body.email || body.username;
const userRepository = getRepository(User); const userRepository = getRepository(User);
const existingUser = await userRepository const existingUser = await userRepository
.createQueryBuilder('user') .createQueryBuilder('user')
.where('user.email = :email', { .where('user.email = :email', {
email: email.toLowerCase(), email: body.email.toLowerCase(),
}) })
.getOne(); .getOne();
@@ -121,7 +108,7 @@ router.post(
} }
const passedExplicitPassword = body.password && body.password.length > 0; const passedExplicitPassword = body.password && body.password.length > 0;
const avatar = gravatarUrl(email, { default: 'mm', size: 200 }); const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 });
if ( if (
!passedExplicitPassword && !passedExplicitPassword &&
@@ -131,9 +118,9 @@ router.post(
} }
const user = new User({ const user = new User({
email,
avatar: body.avatar ?? avatar, avatar: body.avatar ?? avatar,
username: body.username, username: body.username,
email: body.email,
password: body.password, password: body.password,
permissions: settings.main.defaultPermissions, permissions: settings.main.defaultPermissions,
plexToken: '', plexToken: '',
@@ -501,14 +488,16 @@ router.post(
// taken from auth.ts // taken from auth.ts
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
where: { id: 1 }, where: { id: 1 },
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'], select: [
'id',
'jellyfinAuthToken',
'jellyfinDeviceId',
'jellyfinUserId',
],
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const hostname = getHostname();
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
hostname, admin.jellyfinAuthToken ?? '',
settings.jellyfin.apiKey,
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
@@ -516,6 +505,7 @@ router.post(
//const jellyfinUsersResponse = await jellyfinClient.getUsers(); //const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = []; const createdUsers: User[] = [];
const { externalHostname } = getSettings().jellyfin; const { externalHostname } = getSettings().jellyfin;
const hostname = getHostname();
const jellyfinHost = const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0

View File

@@ -98,9 +98,7 @@ userSettingsRoutes.post<
} }
user.username = req.body.username; user.username = req.body.username;
if (user.jellyfinUsername) { user.email = req.body.email ?? user.email;
user.email = req.body.email || user.jellyfinUsername || user.email;
}
// Update quota values only if the user has the correct permissions // Update quota values only if the user has the correct permissions
if ( if (

View File

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

View File

@@ -1,85 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320.03 103.61"><defs><style>.cls-1{fill:#fff}.cls-2{fill:url(#a)}.cls-3{fill:#e5a00d}</style><radialGradient id="a" cx="258.33" cy="51.76" r="42.95" gradientUnits="userSpaceOnUse"><stop offset=".17" stop-color="#f9be03"/><stop offset=".51" stop-color="#e8a50b"/><stop offset="1" stop-color="#cc7c19"/></radialGradient></defs><polygon points="320.03 -.09 289.96 -.09 259.88 51.76 289.96 103.61 320.01 103.61 289.96 51.79" class="cls-1"/><polygon points="226.7 -.09 256.78 -.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76" class="cls-2"/><polygon points="226.7 -.09 256.78 -.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76" class="cls-3"/><path d="M216.32,103.61H156.49V-.09h59.83v18h-37.8V40.69H213.7v18H178.52V85.45h37.8Z" class="cls-1"/><path d="M82.07,103.61V-.09h22V85.45h42.07v18.16Z" class="cls-1"/><path d="M71.66,32.25Q71.66,49,61.2,57.87T31.44,66.73H22v36.88H0V-.09H33.14Q52-.09,61.83,8T71.66,32.25ZM22,48.71h7.24q10.15,0,15.18-4c3.37-2.66,5-6.56,5-11.67s-1.41-9-4.22-11.42S38,17.93,32,17.93H22Z" class="cls-1"/></svg>
<!-- Generator: Adobe Illustrator 26.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="plex-logo"
x="0px"
y="0px"
viewBox="0 0 1000 460.89727"
xml:space="preserve"
sodipodi:docname="plex-logo.svg"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"><metadata
id="metadata25"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs23">
</defs><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#111111"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1017"
id="namedview21"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:zoom="0.27956081"
inkscape:cx="783.06912"
inkscape:cy="-132.85701"
inkscape:window-x="1912"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="plex-logo" />
<style
type="text/css"
id="style2">
.st0{fill:#FFFFFF;}
.st1{fill:#EBAF00;}
</style>
<path
class="st0"
d="m 164.18919,82.43243 c -39.86487,0 -65.540543,11.48648 -87.162163,38.51351 V 91.21621 H 0 v 366.21621 c 0,0 1.3513514,0.67567 5.4054053,1.35135 5.4054057,1.35135 33.7837827,7.43243 54.7297287,-10.13514 18.243244,-15.54054 22.297295,-33.78378 22.297295,-54.05405 v -52.7027 c 22.297301,23.64864 47.297301,33.78378 82.432431,33.78378 75.67567,0 133.78378,-61.48648 133.78378,-143.24323 0,-88.51352 -56.08108,-150 -134.45945,-150 z m -14.86487,223.64864 c -42.56756,0 -76.351351,-35.13513 -76.351351,-77.7027 0,-41.89189 39.864871,-75.67567 76.351351,-75.67567 43.24324,0 76.35135,33.1081 76.35135,76.35135 0,43.24324 -33.78378,77.02702 -76.35135,77.02702 z"
id="path4"
inkscape:connector-curvature="0"
style="fill:#ffffff;stroke-width:6.75675678" /><path
class="st0"
d="m 408.1081,223.64864 c 0,31.75676 3.37838,70.27027 34.45946,112.16216 0.67567,0.67567 2.02702,2.7027 2.02702,2.7027 -12.83783,21.62162 -28.37837,36.48648 -49.32432,36.48648 -16.21622,0 -32.43243,-8.78378 -45.94595,-23.64864 -14.18918,-16.21622 -20.94594,-37.16216 -20.94594,-59.45946 V 0 h 79.05405 z"
id="path6"
inkscape:connector-curvature="0"
style="fill:#ffffff;stroke-width:6.75675678" /><polygon
class="st1"
points="117.9,33.9 104.1,13.5 118.3,13.5 132,33.9 118.3,54.2 104.1,54.2 "
id="polygon8"
style="fill:#ebaf00"
transform="scale(6.7567568)" /><polygon
class="st0"
points="135.7,31.6 148,13.5 133.8,13.5 128.7,21 "
id="polygon10"
style="fill:#ffffff"
transform="scale(6.7567568)" /><path
class="st0"
d="m 869.59458,316.2162 c 0,0 16.2162,22.2973 16.2162,22.2973 15.54058,24.32432 35.8108,36.48648 59.45949,36.48648 25,-0.67567 42.56752,-22.29729 49.3243,-30.4054 0,0 -12.16218,-10.81081 -27.7027,-29.05405 -20.94598,-24.32432 -48.64868,-68.91892 -49.3243,-70.94594 z"
id="path12"
inkscape:connector-curvature="0"
style="fill:#ffffff;stroke-width:6.75675678" /><path
style="fill:#ffffff;stroke-width:6.75675678"
inkscape:connector-curvature="0"
id="path16"
d="m 632.43242,287.16215 c -16.21622,14.86486 -27.02703,22.97297 -49.32432,22.97297 -39.86487,0 -62.83784,-28.37837 -66.21622,-59.45945 h 211.4865 c 1.35131,-4.05406 2.027,-9.45946 2.027,-18.24324 0,-85.81082 -62.83783,-150 -145.27026,-150 -78.37837,0 -142.56756,65.54054 -142.56756,147.29729 0,81.08108 64.18919,145.27026 144.59459,145.27026 56.08108,0 104.72973,-31.75675 131.08105,-87.83783 z M 585.8108,147.29729 c 35.13513,0 61.48648,22.97297 67.56756,53.37838 H 519.59458 c 6.75676,-31.75676 31.75676,-53.37838 66.21622,-53.37838 z"
class="st0" />
</svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

View File

@@ -48,11 +48,11 @@ const DiscoverTvNetwork = () => {
<div className="mt-1 mb-5"> <div className="mt-1 mb-5">
<Header> <Header>
{firstResultData?.network.logoPath ? ( {firstResultData?.network.logoPath ? (
<div className="relative mb-6 flex h-24 justify-center sm:h-32"> <div className="mb-6 flex justify-center">
<Image <Image
src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`} src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
alt={firstResultData.network.name} alt={firstResultData.network.name}
className="object-contain" className="max-h-24 sm:max-h-32"
fill fill
/> />
</div> </div>

View File

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

View File

@@ -48,11 +48,11 @@ const DiscoverMovieStudio = () => {
<div className="mt-1 mb-5"> <div className="mt-1 mb-5">
<Header> <Header>
{firstResultData?.studio.logoPath ? ( {firstResultData?.studio.logoPath ? (
<div className="relative mb-6 flex h-24 justify-center sm:h-32"> <div className="mb-6 flex justify-center">
<Image <Image
src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`} src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
alt={firstResultData.studio.name} alt={firstResultData.studio.name}
className="object-contain" className="max-h-24 sm:max-h-32"
fill fill
/> />
</div> </div>

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import useDeepLinks from '@app/hooks/useDeepLinks';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import ErrorPage from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { import {
@@ -27,8 +27,10 @@ import { MediaServerType } from '@server/constants/server';
import type Issue from '@server/entity/Issue'; import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config'; import getConfig from 'next/config';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
@@ -114,7 +116,7 @@ const IssueDetails = () => {
} }
if (!data || !issueData) { if (!data || !issueData) {
return <ErrorPage statusCode={404} />; return <Error statusCode={404} />;
} }
const belongsToUser = issueData.createdBy.id === currentUser?.id; const belongsToUser = issueData.createdBy.id === currentUser?.id;
@@ -123,14 +125,9 @@ const IssueDetails = () => {
const editFirstComment = async (newMessage: string) => { const editFirstComment = async (newMessage: string) => {
try { try {
const res = await fetch(`/api/v1/issueComment/${firstComment.id}`, { await axios.put(`/api/v1/issueComment/${firstComment.id}`, {
method: 'PUT', message: newMessage,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: newMessage }),
}); });
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.toasteditdescriptionsuccess), { addToast(intl.formatMessage(messages.toasteditdescriptionsuccess), {
appearance: 'success', appearance: 'success',
@@ -147,10 +144,7 @@ const IssueDetails = () => {
const updateIssueStatus = async (newStatus: 'open' | 'resolved') => { const updateIssueStatus = async (newStatus: 'open' | 'resolved') => {
try { try {
const res = await fetch(`/api/v1/issue/${issueData.id}/${newStatus}`, { await axios.post(`/api/v1/issue/${issueData.id}/${newStatus}`);
method: 'POST',
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.toaststatusupdated), { addToast(intl.formatMessage(messages.toaststatusupdated), {
appearance: 'success', appearance: 'success',
@@ -167,10 +161,7 @@ const IssueDetails = () => {
const deleteIssue = async () => { const deleteIssue = async () => {
try { try {
const res = await fetch(`/api/v1/issue/${issueData.id}`, { await axios.delete(`/api/v1/issue/${issueData.id}`);
method: 'DELETE',
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.toastissuedeleted), { addToast(intl.formatMessage(messages.toastissuedeleted), {
appearance: 'success', appearance: 'success',
@@ -288,10 +279,10 @@ const IssueDetails = () => {
} }
className="group ml-1 inline-flex h-full items-center xl:ml-1.5" className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
> >
<CachedImage <Image
src={`/avatarproxy/${issueData.createdBy.avatar}`}
alt=""
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6" className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
src={issueData.createdBy.avatar}
alt=""
width={20} width={20}
height={20} height={20}
/> />
@@ -499,17 +490,9 @@ const IssueDetails = () => {
}} }}
validationSchema={CommentSchema} validationSchema={CommentSchema}
onSubmit={async (values, { resetForm }) => { onSubmit={async (values, { resetForm }) => {
const res = await fetch( await axios.post(`/api/v1/issue/${issueData?.id}/comment`, {
`/api/v1/issue/${issueData?.id}/comment`, message: values.message,
{ });
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: values.message }),
}
);
if (!res.ok) throw new Error();
revalidateIssue(); revalidateIssue();
resetForm(); resetForm();
}} }}

View File

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

View File

@@ -1,4 +1,3 @@
import CachedImage from '@app/components/Common/CachedImage';
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay'; import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
@@ -8,6 +7,8 @@ import {
ClockIcon, ClockIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { CogIcon, UserIcon } from '@heroicons/react/24/solid'; import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
import axios from 'axios';
import Image from 'next/image';
import type { LinkProps } from 'next/link'; import type { LinkProps } from 'next/link';
import Link from 'next/link'; import Link from 'next/link';
import { forwardRef, Fragment } from 'react'; import { forwardRef, Fragment } from 'react';
@@ -38,13 +39,9 @@ const UserDropdown = () => {
const { user, revalidate } = useUser(); const { user, revalidate } = useUser();
const logout = async () => { const logout = async () => {
const res = await fetch('/api/v1/auth/logout', { const response = await axios.post('/api/v1/auth/logout');
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (data?.status === 'ok') { if (response.data?.status === 'ok') {
revalidate(); revalidate();
} }
}; };
@@ -56,9 +53,9 @@ const UserDropdown = () => {
className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500" className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500"
data-testid="user-menu" data-testid="user-menu"
> >
<CachedImage <Image
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10" className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user && user.avatar ? `/avatarproxy/${user.avatar}` : ''} src={user?.avatar || ''}
alt="" alt=""
width={40} width={40}
height={40} height={40}
@@ -79,9 +76,9 @@ const UserDropdown = () => {
<div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur"> <div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur">
<div className="flex flex-col space-y-4 px-4 py-4"> <div className="flex flex-col space-y-4 px-4 py-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<CachedImage <Image
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10" className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user && user.avatar ? `/avatarproxy/${user.avatar}` : ''} src={user?.avatar || ''}
alt="" alt=""
width={40} width={40}
height={40} height={40}
@@ -90,11 +87,9 @@ const UserDropdown = () => {
<span className="truncate text-xl font-semibold text-gray-200"> <span className="truncate text-xl font-semibold text-gray-200">
{user?.displayName} {user?.displayName}
</span> </span>
{user?.displayName?.toLowerCase() !== user?.email && ( <span className="truncate text-sm text-gray-400">
<span className="truncate text-sm text-gray-400"> {user?.email}
{user?.email} </span>
</span>
)}
</div> </div>
</div> </div>
{user && <MiniQuotaDisplay userId={user?.id} />} {user && <MiniQuotaDisplay userId={user?.id} />}

View File

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

View File

@@ -4,6 +4,7 @@ import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { InformationCircleIcon } from '@heroicons/react/24/solid'; import { InformationCircleIcon } from '@heroicons/react/24/solid';
import { ApiErrorCode } from '@server/constants/error'; import { ApiErrorCode } from '@server/constants/error';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config'; import getConfig from 'next/config';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -104,32 +105,18 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
validationSchema={LoginSchema} validationSchema={LoginSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
const res = await fetch('/api/v1/auth/jellyfin', { await axios.post('/api/v1/auth/jellyfin', {
method: 'POST', username: values.username,
headers: { password: values.password,
'Content-Type': 'application/json', hostname: values.hostname,
}, port: values.port,
body: JSON.stringify({ useSsl: values.useSsl,
username: values.username, urlBase: values.urlBase,
password: values.password, email: values.email,
hostname: values.hostname,
port: values.port,
useSsl: values.useSsl,
urlBase: values.urlBase,
email: values.email,
}),
}); });
if (!res.ok) throw new Error(res.statusText, { cause: res });
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
let errorMessage = null; let errorMessage = null;
switch (errorData?.message) { switch (e.response?.data?.message) {
case ApiErrorCode.InvalidUrl: case ApiErrorCode.InvalidUrl:
errorMessage = messages.invalidurlerror; errorMessage = messages.invalidurlerror;
break; break;
@@ -352,18 +339,11 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
validationSchema={LoginSchema} validationSchema={LoginSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
const res = await fetch('/api/v1/auth/jellyfin', { await axios.post('/api/v1/auth/jellyfin', {
method: 'POST', username: values.username,
headers: { password: values.password,
'Content-Type': 'application/json', email: values.username,
},
body: JSON.stringify({
username: values.username,
password: values.password,
email: values.username,
}),
}); });
if (!res.ok) throw new Error();
} catch (e) { } catch (e) {
toasts.addToast( toasts.addToast(
intl.formatMessage( intl.formatMessage(

View File

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

View File

@@ -10,6 +10,7 @@ import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { XCircleIcon } from '@heroicons/react/24/solid'; import { XCircleIcon } from '@heroicons/react/24/solid';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import axios from 'axios';
import getConfig from 'next/config'; import getConfig from 'next/config';
import { useRouter } from 'next/dist/client/router'; import { useRouter } from 'next/dist/client/router';
import Image from 'next/image'; import Image from 'next/image';
@@ -43,28 +44,13 @@ const Login = () => {
const login = async () => { const login = async () => {
setProcessing(true); setProcessing(true);
try { try {
const res = await fetch('/api/v1/auth/plex', { const response = await axios.post('/api/v1/auth/plex', { authToken });
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ authToken }),
});
if (!res.ok) throw new Error(res.statusText, { cause: res });
const data = await res.json();
if (data?.id) { if (response.data?.id) {
revalidate(); revalidate();
} }
} catch (e) { } catch (e) {
let errorData; setError(e.response.data.message);
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
setError(errorData?.message);
setAuthToken(undefined); setAuthToken(undefined);
setProcessing(false); setProcessing(false);
} }

View File

@@ -1,5 +1,4 @@
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import ConfirmButton from '@app/components/Common/ConfirmButton'; import ConfirmButton from '@app/components/Common/ConfirmButton';
import SlideOver from '@app/components/Common/SlideOver'; import SlideOver from '@app/components/Common/SlideOver';
import Tooltip from '@app/components/Common/Tooltip'; import Tooltip from '@app/components/Common/Tooltip';
@@ -27,7 +26,9 @@ import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfa
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import getConfig from 'next/config'; import getConfig from 'next/config';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
@@ -112,29 +113,16 @@ const ManageSlideOver = ({
const deleteMedia = async () => { const deleteMedia = async () => {
if (data.mediaInfo) { if (data.mediaInfo) {
const res = await fetch(`/api/v1/media/${data.mediaInfo.id}`, { await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
method: 'DELETE',
});
if (!res.ok) throw new Error();
revalidate(); revalidate();
onClose();
} }
}; };
const deleteMediaFile = async () => { const deleteMediaFile = async () => {
if (data.mediaInfo) { if (data.mediaInfo) {
const res1 = await fetch(`/api/v1/media/${data.mediaInfo.id}/file`, { await axios.delete(`/api/v1/media/${data.mediaInfo.id}/file`);
method: 'DELETE', await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
});
if (!res1.ok) throw new Error();
const res2 = await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
method: 'DELETE',
});
if (!res2.ok) throw new Error();
revalidate(); revalidate();
onClose();
} }
}; };
@@ -161,16 +149,9 @@ const ManageSlideOver = ({
const markAvailable = async (is4k = false) => { const markAvailable = async (is4k = false) => {
if (data.mediaInfo) { if (data.mediaInfo) {
const res = await fetch(`/api/v1/media/${data.mediaInfo?.id}/available`, { await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, {
method: 'POST', is4k,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
is4k,
}),
}); });
if (!res.ok) throw new Error();
revalidate(); revalidate();
} }
}; };
@@ -355,8 +336,8 @@ const ManageSlideOver = ({
key={`watch-user-${user.id}`} key={`watch-user-${user.id}`}
content={user.displayName} content={user.displayName}
> >
<CachedImage <Image
src={`/avatarproxy/${user.avatar}`} src={user.avatar}
alt={user.displayName} alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105" className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
width={32} width={32}
@@ -516,8 +497,8 @@ const ManageSlideOver = ({
key={`watch-user-${user.id}`} key={`watch-user-${user.id}`}
content={user.displayName} content={user.displayName}
> >
<CachedImage <Image
src={`/avatarproxy/${user.avatar}`} src={user.avatar}
alt={user.displayName} alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105" className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
width={32} width={32}

View File

@@ -57,48 +57,44 @@ const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => {
> >
<div style={{ paddingBottom: '150%' }}> <div style={{ paddingBottom: '150%' }}>
<div className="absolute inset-0 flex h-full w-full flex-col items-center p-2"> <div className="absolute inset-0 flex h-full w-full flex-col items-center p-2">
<div className="relative z-10 grid h-full w-full grid-cols-2 items-center justify-center gap-2 opacity-30"> <div className="relative z-10 flex h-full flex-wrap items-center justify-center opacity-30">
{posters[0] && ( {posters[0] && (
<div className=""> <div className="w-1/2 p-1">
<Image <Image
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`} src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`}
alt="" alt=""
className="rounded-md" className="w-full rounded-md"
width={300} fill
height={450}
/> />
</div> </div>
)} )}
{posters[1] && ( {posters[1] && (
<div className=""> <div className="w-1/2 p-1">
<Image <Image
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`} src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`}
alt="" alt=""
className="rounded-md" className="w-full rounded-md"
width={300} fill
height={450}
/> />
</div> </div>
)} )}
{posters[2] && ( {posters[2] && (
<div className=""> <div className="w-1/2 p-1">
<Image <Image
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`} src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`}
alt="" alt=""
className="rounded-md" className="w-full rounded-md"
width={300} fill
height={450}
/> />
</div> </div>
)} )}
{posters[3] && ( {posters[3] && (
<div className=""> <div className="w-1/2 p-1">
<Image <Image
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`} src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`}
alt="" alt=""
className="rounded-md" className="w-full rounded-md"
width={300} fill
height={450}
/> />
</div> </div>
)} )}

View File

@@ -3,7 +3,6 @@ import RTAudRotten from '@app/assets/rt_aud_rotten.svg';
import RTFresh from '@app/assets/rt_fresh.svg'; import RTFresh from '@app/assets/rt_fresh.svg';
import RTRotten from '@app/assets/rt_rotten.svg'; import RTRotten from '@app/assets/rt_rotten.svg';
import ImdbLogo from '@app/assets/services/imdb.svg'; import ImdbLogo from '@app/assets/services/imdb.svg';
import Spinner from '@app/assets/spinner.svg';
import TmdbLogo from '@app/assets/tmdb_logo.svg'; import TmdbLogo from '@app/assets/tmdb_logo.svg';
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage'; import CachedImage from '@app/components/Common/CachedImage';
@@ -26,7 +25,7 @@ import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import ErrorPage from '@app/pages/_error'; import Error from '@app/pages/_error';
import { sortCrewPriority } from '@app/utils/creditHelpers'; import { sortCrewPriority } from '@app/utils/creditHelpers';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper'; import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
@@ -42,12 +41,10 @@ import {
import { import {
ChevronDoubleDownIcon, ChevronDoubleDownIcon,
ChevronDoubleUpIcon, ChevronDoubleUpIcon,
MinusCircleIcon,
StarIcon,
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import { type RatingResponse } from '@server/api/ratings'; import { type RatingResponse } from '@server/api/ratings';
import { IssueStatus } from '@server/constants/issue'; import { IssueStatus } from '@server/constants/issue';
import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaStatus } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import type { MovieDetails as MovieDetailsType } from '@server/models/Movie'; import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
import { countries } from 'country-flag-icons'; import { countries } from 'country-flag-icons';
@@ -58,7 +55,6 @@ import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages('components.MovieDetails', { const messages = defineMessages('components.MovieDetails', {
@@ -98,12 +94,6 @@ const messages = defineMessages('components.MovieDetails', {
rtaudiencescore: 'Rotten Tomatoes Audience Score', rtaudiencescore: 'Rotten Tomatoes Audience Score',
tmdbuserscore: 'TMDB User Score', tmdbuserscore: 'TMDB User Score',
imdbuserscore: 'IMDB User Score', imdbuserscore: 'IMDB User Score',
watchlistSuccess: '<strong>{title}</strong> added to watchlist successfully!',
watchlistDeleted:
'<strong>{title}</strong> Removed from watchlist successfully!',
watchlistError: 'Something went wrong try again.',
removefromwatchlist: 'Remove From Watchlist',
addtowatchlist: 'Add To Watchlist',
}); });
interface MovieDetailsProps { interface MovieDetailsProps {
@@ -122,12 +112,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
const minStudios = 3; const minStudios = 3;
const [showMoreStudios, setShowMoreStudios] = useState(false); const [showMoreStudios, setShowMoreStudios] = useState(false);
const [showIssueModal, setShowIssueModal] = useState(false); const [showIssueModal, setShowIssueModal] = useState(false);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
!movie?.onUserWatchlist
);
const { publicRuntimeConfig } = getConfig(); const { publicRuntimeConfig } = getConfig();
const { addToast } = useToasts();
const { const {
data, data,
@@ -169,7 +154,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
} }
if (!data) { if (!data) {
return <ErrorPage statusCode={404} />; return <Error statusCode={404} />;
} }
const showAllStudios = data.productionCompanies.length <= minStudios + 1; const showAllStudios = data.productionCompanies.length <= minStudios + 1;
@@ -302,80 +287,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
return intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' }); return intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' });
} }
const onClickWatchlistBtn = async (): Promise<void> => {
setIsUpdating(true);
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 (!res.ok) {
addToast(intl.formatMessage(messages.watchlistError), {
appearance: 'error',
autoDismiss: true,
});
setIsUpdating(false);
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);
};
const onClickDeleteWatchlistBtn = async (): Promise<void> => {
setIsUpdating(true);
try {
const res = await fetch(`/api/v1/watchlist/${movie?.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
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',
autoDismiss: true,
});
} finally {
setIsUpdating(false);
setToggleWatchlist((prevState) => !prevState);
}
};
return ( return (
<div <div
className="media-page" className="media-page"
@@ -497,40 +408,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
</span> </span>
</div> </div>
<div className="media-actions"> <div className="media-actions">
<>
{toggleWatchlist ? (
<Tooltip content={intl.formatMessage(messages.addtowatchlist)}>
<Button
buttonType={'ghost'}
className="z-40 mr-2"
buttonSize={'md'}
onClick={onClickWatchlistBtn}
>
{isUpdating ? (
<Spinner className="h-3" />
) : (
<StarIcon className={'h-3 text-amber-300'} />
)}
</Button>
</Tooltip>
) : (
<Tooltip
content={intl.formatMessage(messages.removefromwatchlist)}
>
<Button
className="z-40 mr-2"
buttonSize={'md'}
onClick={onClickDeleteWatchlistBtn}
>
{isUpdating ? (
<Spinner className="h-3" />
) : (
<MinusCircleIcon className={'h-3'} />
)}
</Button>
</Tooltip>
)}
</>
<PlayButton links={mediaLinks} /> <PlayButton links={mediaLinks} />
<RequestButton <RequestButton
mediaType="movie" mediaType="movie"

View File

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

View File

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

View File

@@ -19,9 +19,10 @@ import {
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import { MediaRequestStatus } from '@server/constants/media'; import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
@@ -58,7 +59,7 @@ const RequestCardPlaceholder = () => {
}; };
interface RequestCardErrorProps { interface RequestCardErrorProps {
requestData?: NonFunctionProperties<MediaRequest>; requestData?: MediaRequest;
} }
const RequestCardError = ({ requestData }: RequestCardErrorProps) => { const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
@@ -73,10 +74,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
}); });
const deleteRequest = async () => { const deleteRequest = async () => {
const res = await fetch(`/api/v1/media/${requestData?.media.id}`, { await axios.delete(`/api/v1/media/${requestData?.media.id}`);
method: 'DELETE',
});
if (!res.ok) throw new Error();
mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded'); mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded');
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
}; };
@@ -115,8 +113,8 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
className="group flex items-center" className="group flex items-center"
> >
<span className="avatar-sm"> <span className="avatar-sm">
<CachedImage <Image
src={`/avatarproxy/${requestData.requestedBy.avatar}`} src={requestData.requestedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
width={20} width={20}
@@ -213,7 +211,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
}; };
interface RequestCardProps { interface RequestCardProps {
request: NonFunctionProperties<MediaRequest>; request: MediaRequest;
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void; onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
} }
@@ -238,19 +236,16 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
data: requestData, data: requestData,
error: requestError, error: requestError,
mutate: revalidate, mutate: revalidate,
} = useSWR<NonFunctionProperties<MediaRequest>>( } = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
`/api/v1/request/${request.id}`, fallbackData: request,
{ refreshInterval: refreshIntervalHelper(
fallbackData: request, {
refreshInterval: refreshIntervalHelper( downloadStatus: request.media.downloadStatus,
{ downloadStatus4k: request.media.downloadStatus4k,
downloadStatus: request.media.downloadStatus, },
downloadStatus4k: request.media.downloadStatus4k, 15000
}, ),
15000 });
),
}
);
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({ const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
mediaUrl: requestData?.media?.mediaUrl, mediaUrl: requestData?.media?.mediaUrl,
@@ -260,22 +255,15 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
}); });
const modifyRequest = async (type: 'approve' | 'decline') => { const modifyRequest = async (type: 'approve' | 'decline') => {
const res = await fetch(`/api/v1/request/${request.id}/${type}`, { const response = await axios.post(`/api/v1/request/${request.id}/${type}`);
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (data) { if (response) {
revalidate(); revalidate();
} }
}; };
const deleteRequest = async () => { const deleteRequest = async () => {
const res = await fetch(`/api/v1/request/${request.id}`, { await axios.delete(`/api/v1/request/${request.id}`);
method: 'DELETE',
});
if (!res.ok) throw new Error();
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
}; };
@@ -283,13 +271,9 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
setRetrying(true); setRetrying(true);
try { try {
const res = await fetch(`/api/v1/request/${request.id}/retry`, { const response = await axios.post(`/api/v1/request/${request.id}/retry`);
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (data) { if (response) {
revalidate(); revalidate();
} }
} catch (e) { } catch (e) {
@@ -389,8 +373,8 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
className="group flex items-center" className="group flex items-center"
> >
<span className="avatar-sm"> <span className="avatar-sm">
<CachedImage <Image
src={`/avatarproxy/${requestData.requestedBy.avatar}`} src={requestData.requestedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
width={20} width={20}

View File

@@ -18,9 +18,10 @@ import {
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import { MediaRequestStatus } from '@server/constants/media'; import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
@@ -42,7 +43,6 @@ const messages = defineMessages('components.RequestList.RequestItem', {
tmdbid: 'TMDB ID', tmdbid: 'TMDB ID',
tvdbid: 'TheTVDB ID', tvdbid: 'TheTVDB ID',
unknowntitle: 'Unknown Title', unknowntitle: 'Unknown Title',
profileName: 'Profile',
}); });
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
@@ -50,7 +50,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
}; };
interface RequestItemErrorProps { interface RequestItemErrorProps {
requestData?: NonFunctionProperties<MediaRequest>; requestData?: MediaRequest;
revalidateList: () => void; revalidateList: () => void;
} }
@@ -62,10 +62,7 @@ const RequestItemError = ({
const { hasPermission } = useUser(); const { hasPermission } = useUser();
const deleteRequest = async () => { const deleteRequest = async () => {
const res = await fetch(`/api/v1/media/${requestData?.media.id}`, { await axios.delete(`/api/v1/media/${requestData?.media.id}`);
method: 'DELETE',
});
if (!res.ok) throw new Error();
revalidateList(); revalidateList();
}; };
@@ -189,8 +186,8 @@ const RequestItemError = ({
className="group flex items-center truncate" className="group flex items-center truncate"
> >
<span className="avatar-sm ml-1.5"> <span className="avatar-sm ml-1.5">
<CachedImage <Image
src={`/avatarproxy/${requestData.requestedBy.avatar}`} src={requestData.requestedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
width={20} width={20}
@@ -248,8 +245,8 @@ const RequestItemError = ({
className="group flex items-center truncate" className="group flex items-center truncate"
> >
<span className="avatar-sm ml-1.5"> <span className="avatar-sm ml-1.5">
<CachedImage <Image
src={`/avatarproxy/${requestData.modifiedBy.avatar}`} src={requestData.modifiedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
width={20} width={20}
@@ -286,7 +283,7 @@ const RequestItemError = ({
}; };
interface RequestItemProps { interface RequestItemProps {
request: NonFunctionProperties<MediaRequest> & { profileName?: string }; request: MediaRequest;
revalidateList: () => void; revalidateList: () => void;
} }
@@ -305,38 +302,32 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const { data: title, error } = useSWR<MovieDetails | TvDetails>( const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? url : null inView ? url : null
); );
const { data: requestData, mutate: revalidate } = useSWR< const { data: requestData, mutate: revalidate } = useSWR<MediaRequest>(
NonFunctionProperties<MediaRequest> `/api/v1/request/${request.id}`,
>(`/api/v1/request/${request.id}`, { {
fallbackData: request, fallbackData: request,
refreshInterval: refreshIntervalHelper( refreshInterval: refreshIntervalHelper(
{ {
downloadStatus: request.media.downloadStatus, downloadStatus: request.media.downloadStatus,
downloadStatus4k: request.media.downloadStatus4k, downloadStatus4k: request.media.downloadStatus4k,
}, },
15000 15000
), ),
}); }
);
const [isRetrying, setRetrying] = useState(false); const [isRetrying, setRetrying] = useState(false);
const modifyRequest = async (type: 'approve' | 'decline') => { const modifyRequest = async (type: 'approve' | 'decline') => {
const res = await fetch(`/api/v1/request/${request.id}/${type}`, { const response = await axios.post(`/api/v1/request/${request.id}/${type}`);
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (data) { if (response) {
revalidate(); revalidate();
} }
}; };
const deleteRequest = async () => { const deleteRequest = async () => {
const res = await fetch(`/api/v1/request/${request.id}`, { await axios.delete(`/api/v1/request/${request.id}`);
method: 'DELETE',
});
if (!res.ok) throw new Error();
revalidateList(); revalidateList();
}; };
@@ -345,12 +336,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
setRetrying(true); setRetrying(true);
try { try {
const res = await fetch(`/api/v1/request/${request.id}/retry`, { const result = await axios.post(`/api/v1/request/${request.id}/retry`);
method: 'POST',
});
if (!res.ok) throw new Error();
const result = await res.json();
revalidate(result.data); revalidate(result.data);
} catch (e) { } catch (e) {
addToast(intl.formatMessage(messages.failedretry), { addToast(intl.formatMessage(messages.failedretry), {
@@ -401,7 +387,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
setShowEditModal(false); setShowEditModal(false);
}} }}
/> />
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-2 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row"> <div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
{title.backdropPath && ( {title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3"> <div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage <CachedImage
@@ -482,7 +468,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
)} )}
</div> </div>
</div> </div>
<div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center gap-1 overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0"> <div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
<div className="card-field"> <div className="card-field">
<span className="card-field-name"> <span className="card-field-name">
{intl.formatMessage(globalMessages.status)} {intl.formatMessage(globalMessages.status)}
@@ -556,8 +542,8 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
className="group flex items-center truncate" className="group flex items-center truncate"
> >
<span className="avatar-sm ml-1.5"> <span className="avatar-sm ml-1.5">
<CachedImage <Image
src={`/avatarproxy/${requestData.requestedBy.avatar}`} src={requestData.requestedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
width={20} width={20}
@@ -615,8 +601,8 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
className="group flex items-center truncate" className="group flex items-center truncate"
> >
<span className="avatar-sm ml-1.5"> <span className="avatar-sm ml-1.5">
<CachedImage <Image
src={`/avatarproxy/${requestData.requestedBy.avatar}`} src={requestData.requestedBy.avatar}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
width={20} width={20}
@@ -632,16 +618,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
</span> </span>
</div> </div>
)} )}
{request.profileName && (
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.profileName)}
</span>
<span className="flex truncate text-sm text-gray-300">
{request.profileName}
</span>
</div>
)}
</div> </div>
</div> </div>
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0"> <div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">

View File

@@ -1,5 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import CachedImage from '@app/components/Common/CachedImage';
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner'; import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import type { User } from '@app/hooks/useUser'; import type { User } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
@@ -15,6 +14,7 @@ import type {
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'; import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import { hasPermission } from '@server/lib/permissions'; import { hasPermission } from '@server/lib/permissions';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import Image from 'next/image';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import Select from 'react-select'; import Select from 'react-select';
@@ -561,8 +561,8 @@ const AdvancedRequester = ({
<span className="inline-block w-full rounded-md shadow-sm"> <span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="focus:shadow-outline-blue relative w-full cursor-default rounded-md border border-gray-700 bg-gray-800 py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5"> <Listbox.Button className="focus:shadow-outline-blue relative w-full cursor-default rounded-md border border-gray-700 bg-gray-800 py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5">
<span className="flex items-center"> <span className="flex items-center">
<CachedImage <Image
src={`/avatarproxy/${selectedUser.avatar}`} src={selectedUser.avatar}
alt="" alt=""
className="h-6 w-6 flex-shrink-0 rounded-full object-cover" className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
width={24} width={24}
@@ -613,8 +613,8 @@ const AdvancedRequester = ({
selected ? 'font-semibold' : 'font-normal' selected ? 'font-semibold' : 'font-normal'
} flex items-center`} } flex items-center`}
> >
<CachedImage <Image
src={`/avatarproxy/${user.avatar}`} src={user.avatar}
alt="" alt=""
className="h-6 w-6 flex-shrink-0 rounded-full object-cover" className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
width={24} width={24}

View File

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

View File

@@ -8,10 +8,10 @@ import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { MediaStatus } from '@server/constants/media'; import { MediaStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces'; import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import axios from 'axios';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
@@ -39,7 +39,7 @@ const messages = defineMessages('components.RequestModal', {
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> { interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
tmdbId: number; tmdbId: number;
is4k?: boolean; is4k?: boolean;
editRequest?: NonFunctionProperties<MediaRequest>; editRequest?: MediaRequest;
onCancel?: () => void; onCancel?: () => void;
onComplete?: (newStatus: MediaStatus) => void; onComplete?: (newStatus: MediaStatus) => void;
onUpdating?: (isUpdating: boolean) => void; onUpdating?: (isUpdating: boolean) => void;
@@ -89,23 +89,15 @@ const MovieRequestModal = ({
tags: requestOverrides.tags, tags: requestOverrides.tags,
}; };
} }
const res = await fetch('/api/v1/request', { const response = await axios.post<MediaRequest>('/api/v1/request', {
method: 'POST', mediaId: data?.id,
headers: { mediaType: 'movie',
'Content-Type': 'application/json', is4k,
}, ...overrideParams,
body: JSON.stringify({
mediaId: data?.id,
mediaType: 'movie',
is4k,
...overrideParams,
}),
}); });
if (!res.ok) throw new Error();
const mediaRequest: MediaRequest = await res.json();
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
if (mediaRequest) { if (response.data) {
if (onComplete) { if (onComplete) {
onComplete( onComplete(
hasPermission( hasPermission(
@@ -144,14 +136,12 @@ const MovieRequestModal = ({
setIsUpdating(true); setIsUpdating(true);
try { try {
const res = await fetch(`/api/v1/request/${editRequest?.id}`, { const response = await axios.delete<MediaRequest>(
method: 'DELETE', `/api/v1/request/${editRequest?.id}`
}); );
if (!res.ok) throw new Error();
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
if (res.status === 204) { if (response.status === 204) {
if (onComplete) { if (onComplete) {
onComplete(MediaStatus.UNKNOWN); onComplete(MediaStatus.UNKNOWN);
} }
@@ -174,27 +164,17 @@ const MovieRequestModal = ({
setIsUpdating(true); setIsUpdating(true);
try { try {
const res = await fetch(`/api/v1/request/${editRequest?.id}`, { await axios.put(`/api/v1/request/${editRequest?.id}`, {
method: 'PUT', mediaType: 'movie',
headers: { serverId: requestOverrides?.server,
'Content-Type': 'application/json', profileId: requestOverrides?.profile,
}, rootFolder: requestOverrides?.folder,
body: JSON.stringify({ userId: requestOverrides?.user?.id,
mediaType: 'movie', tags: requestOverrides?.tags,
serverId: requestOverrides?.server,
profileId: requestOverrides?.profile,
rootFolder: requestOverrides?.folder,
userId: requestOverrides?.user?.id,
tags: requestOverrides?.tags,
}),
}); });
if (!res.ok) throw new Error();
if (alsoApproveRequest) { if (alsoApproveRequest) {
const res = await fetch(`/api/v1/request/${editRequest?.id}/approve`, { await axios.post(`/api/v1/request/${editRequest?.id}/approve`);
method: 'POST',
});
if (!res.ok) throw new Error();
} }
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');

View File

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

View File

@@ -4,14 +4,13 @@ import TvRequestModal from '@app/components/RequestModal/TvRequestModal';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import type { MediaStatus } from '@server/constants/media'; import type { MediaStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
interface RequestModalProps { interface RequestModalProps {
show: boolean; show: boolean;
type: 'movie' | 'tv' | 'collection'; type: 'movie' | 'tv' | 'collection';
tmdbId: number; tmdbId: number;
is4k?: boolean; is4k?: boolean;
editRequest?: NonFunctionProperties<MediaRequest>; editRequest?: MediaRequest;
onComplete?: (newStatus: MediaStatus) => void; onComplete?: (newStatus: MediaStatus) => void;
onCancel?: () => void; onCancel?: () => void;
onUpdating?: (isUpdating: boolean) => void; onUpdating?: (isUpdating: boolean) => void;

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import type {
ProductionCompany, ProductionCompany,
WatchProviderDetails, WatchProviderDetails,
} from '@server/models/common'; } from '@server/models/common';
import axios from 'axios';
import { orderBy } from 'lodash'; import { orderBy } from 'lodash';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -68,9 +69,11 @@ export const CompanySelector = ({
return; return;
} }
const res = await fetch(`/api/v1/studio/${defaultValue}`); const response = await axios.get<ProductionCompany>(
if (!res.ok) throw new Error(); `/api/v1/studio/${defaultValue}`
const studio: ProductionCompany = await res.json(); );
const studio = response.data;
setDefaultDataValue([ setDefaultDataValue([
{ {
@@ -88,15 +91,16 @@ export const CompanySelector = ({
return []; return [];
} }
const res = await fetch( const results = await axios.get<TmdbCompanySearchResponse>(
`/api/v1/search/company?query=${encodeURIExtraParams(inputValue)}` '/api/v1/search/company',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
); );
if (!res.ok) {
throw new Error('Network response was not ok');
}
const results: TmdbCompanySearchResponse = await res.json();
return results.results.map((result) => ({ return results.data.results.map((result) => ({
label: result.name, label: result.name,
value: result.id, value: result.id,
})); }));
@@ -150,15 +154,11 @@ export const GenreSelector = ({
const genres = defaultValue.split(','); const genres = defaultValue.split(',');
const res = await fetch(`/api/v1/genres/${type}`); const response = await axios.get<TmdbGenre[]>(`/api/v1/genres/${type}`);
if (!res.ok) {
throw new Error('Network response was not ok');
}
const response: TmdbGenre[] = await res.json();
const genreData = genres const genreData = genres
.filter((genre) => response.find((gd) => gd.id === Number(genre))) .filter((genre) => response.data.find((gd) => gd.id === Number(genre)))
.map((g) => response.find((gd) => gd.id === Number(g))) .map((g) => response.data.find((gd) => gd.id === Number(g)))
.map((g) => ({ .map((g) => ({
label: g?.name ?? '', label: g?.name ?? '',
value: g?.id ?? 0, value: g?.id ?? 0,
@@ -171,11 +171,11 @@ export const GenreSelector = ({
}, [defaultValue, type]); }, [defaultValue, type]);
const loadGenreOptions = async (inputValue: string) => { const loadGenreOptions = async (inputValue: string) => {
const res = await fetch(`/api/v1/discover/genreslider/${type}`); const results = await axios.get<GenreSliderItem[]>(
if (!res.ok) throw new Error(); `/api/v1/discover/genreslider/${type}`
const results: GenreSliderItem[] = await res.json(); );
return results return results.data
.map((result) => ({ .map((result) => ({
label: result.name, label: result.name,
value: result.id, value: result.id,
@@ -222,13 +222,11 @@ export const KeywordSelector = ({
const keywords = await Promise.all( const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => { defaultValue.split(',').map(async (keywordId) => {
const res = await fetch(`/api/v1/keyword/${keywordId}`); const keyword = await axios.get<Keyword>(
if (!res.ok) { `/api/v1/keyword/${keywordId}`
throw new Error('Network response was not ok'); );
}
const keyword: Keyword = await res.json();
return keyword; return keyword.data;
}) })
); );
@@ -244,15 +242,16 @@ export const KeywordSelector = ({
}, [defaultValue]); }, [defaultValue]);
const loadKeywordOptions = async (inputValue: string) => { const loadKeywordOptions = async (inputValue: string) => {
const res = await fetch( const results = await axios.get<TmdbKeywordSearchResponse>(
`/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}` '/api/v1/search/keyword',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
); );
if (!res.ok) {
throw new Error('Network response was not ok');
}
const results: TmdbKeywordSearchResponse = await res.json();
return results.results.map((result) => ({ return results.data.results.map((result) => ({
label: result.name, label: result.name,
value: result.id, value: result.id,
})); }));

View File

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

View File

@@ -5,6 +5,7 @@ import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline'; import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -72,23 +73,16 @@ const NotificationsDiscord = () => {
validationSchema={NotificationsDiscordSchema} validationSchema={NotificationsDiscordSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
const res = await fetch('/api/v1/settings/notifications/discord', { await axios.post('/api/v1/settings/notifications/discord', {
method: 'POST', enabled: values.enabled,
headers: { types: values.types,
'Content-Type': 'application/json', options: {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
enableMentions: values.enableMentions,
}, },
body: JSON.stringify({
enabled: values.enabled,
types: values.types,
options: {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
enableMentions: values.enableMentions,
},
}),
}); });
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.discordsettingssaved), { addToast(intl.formatMessage(messages.discordsettingssaved), {
appearance: 'success', appearance: 'success',
@@ -127,26 +121,16 @@ const NotificationsDiscord = () => {
toastId = id; toastId = id;
} }
); );
const res = await fetch( await axios.post('/api/v1/settings/notifications/discord/test', {
'/api/v1/settings/notifications/discord/test', enabled: true,
{ types: values.types,
method: 'POST', options: {
headers: { botUsername: values.botUsername,
'Content-Type': 'application/json', botAvatarUrl: values.botAvatarUrl,
}, webhookUrl: values.webhookUrl,
body: JSON.stringify({ enableMentions: values.enableMentions,
enabled: true, },
types: values.types, });
options: {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
enableMentions: values.enableMentions,
},
}),
}
);
if (!res.ok) throw new Error();
if (toastId) { if (toastId) {
removeToast(toastId); removeToast(toastId);

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline'; import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -68,21 +69,14 @@ const NotificationsLunaSea = () => {
validationSchema={NotificationsLunaSeaSchema} validationSchema={NotificationsLunaSeaSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
const res = await fetch('/api/v1/settings/notifications/lunasea', { await axios.post('/api/v1/settings/notifications/lunasea', {
method: 'POST', enabled: values.enabled,
headers: { types: values.types,
'Content-Type': 'application/json', options: {
webhookUrl: values.webhookUrl,
profileName: values.profileName,
}, },
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), { addToast(intl.formatMessage(messages.settingsSaved), {
appearance: 'success', appearance: 'success',
autoDismiss: true, autoDismiss: true,
@@ -120,24 +114,14 @@ const NotificationsLunaSea = () => {
toastId = id; toastId = id;
} }
); );
const res = await fetch( await axios.post('/api/v1/settings/notifications/lunasea/test', {
'/api/v1/settings/notifications/lunasea/test', enabled: true,
{ types: values.types,
method: 'POST', options: {
headers: { webhookUrl: values.webhookUrl,
'Content-Type': 'application/json', profileName: values.profileName,
}, },
body: JSON.stringify({ });
enabled: true,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
profileName: values.profileName,
},
}),
}
);
if (!res.ok) throw new Error();
if (toastId) { if (toastId) {
removeToast(toastId); removeToast(toastId);

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline'; import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -64,20 +65,13 @@ const NotificationsSlack = () => {
validationSchema={NotificationsSlackSchema} validationSchema={NotificationsSlackSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
const res = await fetch('/api/v1/settings/notifications/slack', { await axios.post('/api/v1/settings/notifications/slack', {
method: 'POST', enabled: values.enabled,
headers: { types: values.types,
'Content-Type': 'application/json', options: {
webhookUrl: values.webhookUrl,
}, },
body: JSON.stringify({
enabled: values.enabled,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
},
}),
}); });
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.slacksettingssaved), { addToast(intl.formatMessage(messages.slacksettingssaved), {
appearance: 'success', appearance: 'success',
autoDismiss: true, autoDismiss: true,
@@ -115,23 +109,13 @@ const NotificationsSlack = () => {
toastId = id; toastId = id;
} }
); );
const res = await fetch( await axios.post('/api/v1/settings/notifications/slack/test', {
'/api/v1/settings/notifications/slack/test', enabled: true,
{ types: values.types,
method: 'POST', options: {
headers: { webhookUrl: values.webhookUrl,
'Content-Type': 'application/json', },
}, });
body: JSON.stringify({
enabled: true,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
},
}),
}
);
if (!res.ok) throw new Error();
if (toastId) { if (toastId) {
removeToast(toastId); removeToast(toastId);

View File

@@ -5,6 +5,7 @@ import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline'; import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -83,23 +84,16 @@ const NotificationsTelegram = () => {
validationSchema={NotificationsTelegramSchema} validationSchema={NotificationsTelegramSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
const res = await fetch('/api/v1/settings/notifications/telegram', { await axios.post('/api/v1/settings/notifications/telegram', {
method: 'POST', enabled: values.enabled,
headers: { types: values.types,
'Content-Type': 'application/json', options: {
botAPI: values.botAPI,
chatId: values.chatId,
sendSilently: values.sendSilently,
botUsername: values.botUsername,
}, },
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), { addToast(intl.formatMessage(messages.telegramsettingssaved), {
appearance: 'success', appearance: 'success',
@@ -138,26 +132,16 @@ const NotificationsTelegram = () => {
toastId = id; toastId = id;
} }
); );
const res = await fetch( await axios.post('/api/v1/settings/notifications/telegram/test', {
'/api/v1/settings/notifications/telegram/test', enabled: true,
{ types: values.types,
method: 'POST', options: {
headers: { botAPI: values.botAPI,
'Content-Type': 'application/json', chatId: values.chatId,
}, sendSilently: values.sendSilently,
body: JSON.stringify({ botUsername: values.botUsername,
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) { if (toastId) {
removeToast(toastId); removeToast(toastId);

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import type { RadarrSettings } from '@server/lib/settings'; import type { RadarrSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Formik } from 'formik'; import { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -165,24 +166,19 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
}) => { }) => {
setIsTesting(true); setIsTesting(true);
try { try {
const res = await fetch('/api/v1/settings/radarr/test', { const response = await axios.post<TestResponse>(
method: 'POST', '/api/v1/settings/radarr/test',
headers: { {
'Content-Type': 'application/json',
},
body: JSON.stringify({
hostname, hostname,
apiKey, apiKey,
port: Number(port), port: Number(port),
baseUrl, baseUrl,
useSsl, useSsl,
}), }
}); );
if (!res.ok) throw new Error();
const data = await res.json();
setIsValidated(true); setIsValidated(true);
setTestResponse(data); setTestResponse(response.data);
if (initialLoad.current) { if (initialLoad.current) {
addToast(intl.formatMessage(messages.toastRadarrTestSuccess), { addToast(intl.formatMessage(messages.toastRadarrTestSuccess), {
appearance: 'success', appearance: 'success',
@@ -275,23 +271,12 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
tagRequests: values.tagRequests, tagRequests: values.tagRequests,
}; };
if (!radarr) { if (!radarr) {
const res = await fetch('/api/v1/settings/radarr', { await axios.post('/api/v1/settings/radarr', submission);
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(submission),
});
if (!res.ok) throw new Error();
} else { } else {
const res = await fetch(`/api/v1/settings/radarr/${radarr.id}`, { await axios.put(
method: 'PUT', `/api/v1/settings/radarr/${radarr.id}`,
headers: { submission
'Content-Type': 'application/json', );
},
body: JSON.stringify(submission),
});
if (!res.ok) throw new Error();
} }
onSave(); onSave();

View File

@@ -1,13 +1,13 @@
import Badge from '@app/components/Common/Badge'; import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import LibraryItem from '@app/components/Settings/LibraryItem'; import LibraryItem from '@app/components/Settings/LibraryItem';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import { ApiErrorCode } from '@server/constants/error'; import { ApiErrorCode } from '@server/constants/error';
import type { JellyfinSettings } from '@server/lib/settings'; import type { JellyfinSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Formik } from 'formik'; import { Field, Formik } from 'formik';
import getConfig from 'next/config'; import getConfig from 'next/config';
import { useState } from 'react'; import { useState } from 'react';
@@ -31,14 +31,13 @@ const messages = defineMessages('components.Settings', {
jellyfinSettingsSuccess: '{mediaServerName} settings saved successfully!', jellyfinSettingsSuccess: '{mediaServerName} settings saved successfully!',
jellyfinSettings: '{mediaServerName} Settings', jellyfinSettings: '{mediaServerName} Settings',
jellyfinSettingsDescription: jellyfinSettingsDescription:
'Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page. You can also change the Jellyfin API key, which was automatically generated previously.', 'Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page.',
externalUrl: 'External URL', externalUrl: 'External URL',
hostname: 'Hostname or IP Address', hostname: 'Hostname or IP Address',
port: 'Port', port: 'Port',
enablessl: 'Use SSL', enablessl: 'Use SSL',
urlBase: 'URL Base', urlBase: 'URL Base',
jellyfinForgotPasswordUrl: 'Forgot Password URL', jellyfinForgotPasswordUrl: 'Forgot Password URL',
apiKey: 'API key',
jellyfinSyncFailedNoLibrariesFound: 'No libraries were found', jellyfinSyncFailedNoLibrariesFound: 'No libraries were found',
jellyfinSyncFailedAutomaticGroupedFolders: jellyfinSyncFailedAutomaticGroupedFolders:
'Custom authentication with Automatic Library Grouping not supported', 'Custom authentication with Automatic Library Grouping not supported',
@@ -168,25 +167,13 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
} }
try { try {
const searchParams = new URLSearchParams({ await axios.get('/api/v1/settings/jellyfin/library', {
sync: params.sync ? 'true' : 'false', params,
...(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); setIsSyncing(false);
revalidate(); revalidate();
} catch (e) { } catch (e) {
let errorData; if (e.response.data.message === 'SYNC_ERROR_GROUPED_FOLDERS') {
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
if (errorData?.message === 'SYNC_ERROR_GROUPED_FOLDERS') {
toasts.addToast( toasts.addToast(
intl.formatMessage( intl.formatMessage(
messages.jellyfinSyncFailedAutomaticGroupedFolders messages.jellyfinSyncFailedAutomaticGroupedFolders
@@ -196,7 +183,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
appearance: 'warning', appearance: 'warning',
} }
); );
} else if (errorData?.message === 'SYNC_ERROR_NO_LIBRARIES') { } else if (e.response.data.message === 'SYNC_ERROR_NO_LIBRARIES') {
toasts.addToast( toasts.addToast(
intl.formatMessage(messages.jellyfinSyncFailedNoLibrariesFound), intl.formatMessage(messages.jellyfinSyncFailedNoLibrariesFound),
{ {
@@ -219,32 +206,16 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
}; };
const startScan = async () => { const startScan = async () => {
const res = await fetch('/api/v1/settings/jellyfin/sync', { await axios.post('/api/v1/settings/jellyfin/sync', {
method: 'POST', start: true,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
start: true,
}),
}); });
if (!res.ok) throw new Error();
revalidateSync(); revalidateSync();
}; };
const cancelScan = async () => { const cancelScan = async () => {
const res = await fetch('/api/v1/settings/jellyfin/sync', { await axios.post('/api/v1/settings/jellyfin/sync', {
method: 'POST', cancel: true,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
cancel: true,
}),
}); });
if (!res.ok) throw new Error();
revalidateSync(); revalidateSync();
}; };
@@ -259,19 +230,15 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
.join(','); .join(',');
} }
const searchParams = new URLSearchParams(params.enable ? params : {}); await axios.get('/api/v1/settings/jellyfin/library', {
const res = await fetch( params,
`/api/v1/settings/jellyfin/library?${searchParams.toString()}` });
); } else {
if (!res.ok) throw new Error(); await axios.get('/api/v1/settings/jellyfin/library', {
} else { params: {
const searchParams = new URLSearchParams({ enable: [...activeLibraries, libraryId].join(','),
enable: [...activeLibraries, libraryId].join(','), },
}); });
const res = await fetch(
`/api/v1/settings/jellyfin/library?${searchParams.toString()}`
);
if (!res.ok) throw new Error();
} }
if (onComplete) { if (onComplete) {
onComplete(); onComplete();
@@ -446,121 +413,105 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
</div> </div>
</div> </div>
</div> </div>
<div className="mt-10 mb-6"> {showAdvancedSettings && (
<h3 className="heading"> <>
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby' <div className="mt-10 mb-6">
? intl.formatMessage(messages.jellyfinSettings, { <h3 className="heading">
mediaServerName: 'Emby', {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
}) ? intl.formatMessage(messages.jellyfinSettings, {
: intl.formatMessage(messages.jellyfinSettings, { mediaServerName: 'Emby',
mediaServerName: 'Jellyfin', })
})} : intl.formatMessage(messages.jellyfinSettings, {
</h3> mediaServerName: 'Jellyfin',
<p className="description"> })}
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby' </h3>
? intl.formatMessage(messages.jellyfinSettingsDescription, { <p className="description">
mediaServerName: 'Emby', {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
}) ? intl.formatMessage(messages.jellyfinSettingsDescription, {
: intl.formatMessage(messages.jellyfinSettingsDescription, { mediaServerName: 'Emby',
mediaServerName: 'Jellyfin', })
})} : intl.formatMessage(messages.jellyfinSettingsDescription, {
</p> mediaServerName: 'Jellyfin',
</div> })}
<Formik </p>
initialValues={{ </div>
hostname: data?.ip, <Formik
port: data?.port ?? 8096, initialValues={{
useSsl: data?.useSsl, hostname: data?.ip,
urlBase: data?.urlBase || '', port: data?.port ?? 8096,
jellyfinExternalUrl: data?.externalHostname || '', useSsl: data?.useSsl,
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '', urlBase: data?.urlBase || '',
apiKey: data?.apiKey, jellyfinExternalUrl: data?.externalHostname || '',
}} jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
validationSchema={JellyfinSettingsSchema} }}
onSubmit={async (values) => { validationSchema={JellyfinSettingsSchema}
try { onSubmit={async (values) => {
const res = await fetch('/api/v1/settings/jellyfin', { try {
method: 'POST', await axios.post('/api/v1/settings/jellyfin', {
headers: { ip: values.hostname,
'Content-Type': 'application/json', port: Number(values.port),
}, useSsl: values.useSsl,
body: JSON.stringify({ urlBase: values.urlBase,
ip: values.hostname, externalHostname: values.jellyfinExternalUrl,
port: Number(values.port), jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
useSsl: values.useSsl, } as JellyfinSettings);
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( addToast(
intl.formatMessage(messages.jellyfinSettingsSuccess, { intl.formatMessage(messages.jellyfinSettingsSuccess, {
mediaServerName: mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby' ? 'Emby'
: 'Jellyfin', : 'Jellyfin',
}), }),
{ {
autoDismiss: true, autoDismiss: true,
appearance: 'success', appearance: 'success',
}
);
} catch (e) {
if (e.response?.data?.message === ApiErrorCode.InvalidUrl) {
addToast(
intl.formatMessage(messages.invalidurlerror, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
appearance: 'error',
}
);
} else {
addToast(
intl.formatMessage(messages.jellyfinSettingsFailure, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
appearance: 'error',
}
);
}
} finally {
revalidate();
} }
); }}
} catch (e) { >
let errorData; {({
try { errors,
errorData = await e.cause?.text(); touched,
errorData = JSON.parse(errorData); values,
} catch { setFieldValue,
/* empty */ handleSubmit,
} isSubmitting,
if (errorData?.message === ApiErrorCode.InvalidUrl) { isValid,
addToast( }) => {
intl.formatMessage(messages.invalidurlerror, { return (
mediaServerName: <form className="section" onSubmit={handleSubmit}>
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
appearance: 'error',
}
);
} else {
addToast(
intl.formatMessage(messages.jellyfinSettingsFailure, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
appearance: 'error',
}
);
}
} finally {
revalidate();
}
}}
>
{({
errors,
touched,
values,
setFieldValue,
handleSubmit,
isSubmitting,
isValid,
}) => {
return (
<form className="section" onSubmit={handleSubmit}>
{showAdvancedSettings && (
<>
<div className="form-row"> <div className="form-row">
<label htmlFor="hostname" className="text-label"> <label htmlFor="hostname" className="text-label">
{intl.formatMessage(messages.hostname)} {intl.formatMessage(messages.hostname)}
@@ -622,29 +573,6 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
/> />
</div> </div>
</div> </div>
</>
)}
<div className="form-row">
<label htmlFor="apiKey" className="text-label">
{intl.formatMessage(messages.apiKey)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
as="field"
type="text"
inputMode="url"
id="apiKey"
name="apiKey"
/>
</div>
{errors.apiKey && touched.apiKey && (
<div className="error">{errors.apiKey}</div>
)}
</div>
</div>
{showAdvancedSettings && (
<>
<div className="form-row"> <div className="form-row">
<label htmlFor="urlBase" className="text-label"> <label htmlFor="urlBase" className="text-label">
{intl.formatMessage(messages.urlBase)} {intl.formatMessage(messages.urlBase)}
@@ -665,73 +593,75 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
)} )}
</div> </div>
</div> </div>
</> <div className="form-row">
)} <label htmlFor="jellyfinExternalUrl" className="text-label">
<div className="form-row"> {intl.formatMessage(messages.externalUrl)}
<label htmlFor="jellyfinExternalUrl" className="text-label"> </label>
{intl.formatMessage(messages.externalUrl)} <div className="form-input-area">
</label> <div className="form-input-field">
<div className="form-input-area"> <Field
<div className="form-input-field"> type="text"
<Field inputMode="url"
type="text" id="jellyfinExternalUrl"
inputMode="url" name="jellyfinExternalUrl"
id="jellyfinExternalUrl" />
name="jellyfinExternalUrl"
/>
</div>
{errors.jellyfinExternalUrl &&
touched.jellyfinExternalUrl && (
<div className="error">{errors.jellyfinExternalUrl}</div>
)}
</div>
</div>
<div className="form-row">
<label
htmlFor="jellyfinForgotPasswordUrl"
className="text-label"
>
{intl.formatMessage(messages.jellyfinForgotPasswordUrl)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
type="text"
inputMode="url"
id="jellyfinForgotPasswordUrl"
name="jellyfinForgotPasswordUrl"
/>
</div>
{errors.jellyfinForgotPasswordUrl &&
touched.jellyfinForgotPasswordUrl && (
<div className="error">
{errors.jellyfinForgotPasswordUrl}
</div> </div>
)} {errors.jellyfinExternalUrl &&
</div> touched.jellyfinExternalUrl && (
</div> <div className="error">
<div className="actions"> {errors.jellyfinExternalUrl}
<div className="flex justify-end"> </div>
<span className="ml-3 inline-flex rounded-md shadow-sm"> )}
<Button </div>
buttonType="primary" </div>
type="submit" <div className="form-row">
disabled={isSubmitting || !isValid} <label
htmlFor="jellyfinForgotPasswordUrl"
className="text-label"
> >
<ArrowDownOnSquareIcon /> {intl.formatMessage(messages.jellyfinForgotPasswordUrl)}
<span> </label>
{isSubmitting <div className="form-input-area">
? intl.formatMessage(globalMessages.saving) <div className="form-input-field">
: intl.formatMessage(globalMessages.save)} <Field
type="text"
inputMode="url"
id="jellyfinForgotPasswordUrl"
name="jellyfinForgotPasswordUrl"
/>
</div>
{errors.jellyfinForgotPasswordUrl &&
touched.jellyfinForgotPasswordUrl && (
<div className="error">
{errors.jellyfinForgotPasswordUrl}
</div>
)}
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
</span>
</Button>
</span> </span>
</Button> </div>
</span> </div>
</div> </form>
</div> );
</form> }}
); </Formik>
}} </>
</Formik> )}
</> </>
); );
}; };

View File

@@ -19,6 +19,7 @@ import type {
CacheResponse, CacheResponse,
} from '@server/interfaces/api/settingsInterfaces'; } from '@server/interfaces/api/settingsInterfaces';
import type { JobId } from '@server/lib/settings'; import type { JobId } from '@server/lib/settings';
import axios from 'axios';
import cronstrue from 'cronstrue/i18n'; import cronstrue from 'cronstrue/i18n';
import { Fragment, useReducer, useState } from 'react'; import { Fragment, useReducer, useState } from 'react';
import type { MessageDescriptor } from 'react-intl'; import type { MessageDescriptor } from 'react-intl';
@@ -81,7 +82,6 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
'When enabled in settings, Jellyseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.', 'When enabled in settings, Jellyseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
imagecachecount: 'Images Cached', imagecachecount: 'Images Cached',
imagecachesize: 'Total Cache Size', imagecachesize: 'Total Cache Size',
useravatars: 'User Avatars',
} }
); );
@@ -173,10 +173,7 @@ const SettingsJobs = () => {
} }
const runJob = async (job: Job) => { const runJob = async (job: Job) => {
const res = await fetch(`/api/v1/settings/jobs/${job.id}/run`, { await axios.post(`/api/v1/settings/jobs/${job.id}/run`);
method: 'POST',
});
if (!res.ok) throw new Error();
addToast( addToast(
intl.formatMessage(messages.jobstarted, { intl.formatMessage(messages.jobstarted, {
jobname: intl.formatMessage(messages[job.id] ?? messages.unknownJob), jobname: intl.formatMessage(messages[job.id] ?? messages.unknownJob),
@@ -190,10 +187,7 @@ const SettingsJobs = () => {
}; };
const cancelJob = async (job: Job) => { const cancelJob = async (job: Job) => {
const res = await fetch(`/api/v1/settings/jobs/${job.id}/cancel`, { await axios.post(`/api/v1/settings/jobs/${job.id}/cancel`);
method: 'POST',
});
if (!res.ok) throw new Error();
addToast( addToast(
intl.formatMessage(messages.jobcancelled, { intl.formatMessage(messages.jobcancelled, {
jobname: intl.formatMessage(messages[job.id] ?? messages.unknownJob), jobname: intl.formatMessage(messages[job.id] ?? messages.unknownJob),
@@ -207,10 +201,7 @@ const SettingsJobs = () => {
}; };
const flushCache = async (cache: CacheItem) => { const flushCache = async (cache: CacheItem) => {
const res = await fetch(`/api/v1/settings/cache/${cache.id}/flush`, { await axios.post(`/api/v1/settings/cache/${cache.id}/flush`);
method: 'POST',
});
if (!res.ok) throw new Error();
addToast( addToast(
intl.formatMessage(messages.cacheflushed, { cachename: cache.name }), intl.formatMessage(messages.cacheflushed, { cachename: cache.name }),
{ {
@@ -237,19 +228,12 @@ const SettingsJobs = () => {
} }
setIsSaving(true); setIsSaving(true);
const res = await fetch( await axios.post(
`/api/v1/settings/jobs/${jobModalState.job.id}/schedule`, `/api/v1/settings/jobs/${jobModalState.job.id}/schedule`,
{ {
method: 'POST', schedule: jobScheduleCron.join(' '),
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
schedule: jobScheduleCron.join(' '),
}),
} }
); );
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.jobScheduleEditSaved), { addToast(intl.formatMessage(messages.jobScheduleEditSaved), {
appearance: 'success', appearance: 'success',
@@ -559,19 +543,6 @@ const SettingsJobs = () => {
{formatBytes(cacheData?.imageCache.tmdb.size ?? 0)} {formatBytes(cacheData?.imageCache.tmdb.size ?? 0)}
</Table.TD> </Table.TD>
</tr> </tr>
<tr>
<Table.TD>
{intl.formatMessage(messages.useravatars)} (avatar)
</Table.TD>
<Table.TD>
{intl.formatNumber(
cacheData?.imageCache.avatar.imageCount ?? 0
)}
</Table.TD>
<Table.TD>
{formatBytes(cacheData?.imageCache.avatar.size ?? 0)}
</Table.TD>
</tr>
</Table.TBody> </Table.TBody>
</Table> </Table>
</div> </div>

View File

@@ -17,6 +17,7 @@ import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import { ArrowPathIcon } from '@heroicons/react/24/solid'; import { ArrowPathIcon } from '@heroicons/react/24/solid';
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces'; import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
import type { MainSettings } from '@server/lib/settings'; import type { MainSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
@@ -86,10 +87,7 @@ const SettingsMain = () => {
const regenerate = async () => { const regenerate = async () => {
try { try {
const res = await fetch('/api/v1/settings/main/regenerate', { await axios.post('/api/v1/settings/main/regenerate');
method: 'POST',
});
if (!res.ok) throw new Error();
revalidate(); revalidate();
addToast(intl.formatMessage(messages.toastApiKeySuccess), { addToast(intl.formatMessage(messages.toastApiKeySuccess), {
@@ -142,25 +140,18 @@ const SettingsMain = () => {
validationSchema={MainSettingsSchema} validationSchema={MainSettingsSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
const res = await fetch('/api/v1/settings/main', { await axios.post('/api/v1/settings/main', {
method: 'POST', applicationTitle: values.applicationTitle,
headers: { applicationUrl: values.applicationUrl,
'Content-Type': 'application/json', csrfProtection: values.csrfProtection,
}, hideAvailable: values.hideAvailable,
body: JSON.stringify({ locale: values.locale,
applicationTitle: values.applicationTitle, region: values.region,
applicationUrl: values.applicationUrl, originalLanguage: values.originalLanguage,
csrfProtection: values.csrfProtection, partialRequestsEnabled: values.partialRequestsEnabled,
hideAvailable: values.hideAvailable, trustProxy: values.trustProxy,
locale: values.locale, cacheImages: values.cacheImages,
region: values.region,
originalLanguage: values.originalLanguage,
partialRequestsEnabled: values.partialRequestsEnabled,
trustProxy: values.trustProxy,
cacheImages: values.cacheImages,
}),
}); });
if (!res.ok) throw new Error();
mutate('/api/v1/settings/public'); mutate('/api/v1/settings/public');
mutate('/api/v1/status'); mutate('/api/v1/status');

View File

@@ -16,6 +16,7 @@ import {
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import type { PlexDevice } from '@server/interfaces/api/plexInterfaces'; import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
import type { PlexSettings, TautulliSettings } from '@server/lib/settings'; import type { PlexSettings, TautulliSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Formik } from 'formik'; import { Field, Formik } from 'formik';
import { orderBy } from 'lodash'; import { orderBy } from 'lodash';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
@@ -240,15 +241,9 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
params.enable = activeLibraries.join(','); params.enable = activeLibraries.join(',');
} }
const searchParams = new URLSearchParams({ await axios.get('/api/v1/settings/plex/library', {
sync: params.sync ? 'true' : 'false', params,
...(params.enable ? { enable: params.enable } : {}),
}); });
const res = await fetch(
`/api/v1/settings/plex/library?${searchParams.toString()}`
);
if (!res.ok) throw new Error();
setIsSyncing(false); setIsSyncing(false);
revalidate(); revalidate();
}; };
@@ -267,12 +262,11 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
toastId = id; toastId = id;
} }
); );
const res = await fetch('/api/v1/settings/plex/devices/servers'); const response = await axios.get<PlexDevice[]>(
if (!res.ok) throw new Error(); '/api/v1/settings/plex/devices/servers'
const data: PlexDevice[] = await res.json(); );
if (response.data) {
if (data) { setAvailableServers(response.data);
setAvailableServers(data);
} }
if (toastId) { if (toastId) {
removeToast(toastId); removeToast(toastId);
@@ -295,30 +289,16 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
}; };
const startScan = async () => { const startScan = async () => {
const res = await fetch('/api/v1/settings/plex/sync', { await axios.post('/api/v1/settings/plex/sync', {
method: 'POST', start: true,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
start: true,
}),
}); });
if (!res.ok) throw new Error();
revalidateSync(); revalidateSync();
}; };
const cancelScan = async () => { const cancelScan = async () => {
const res = await fetch('/api/v1/settings/plex/sync', { await axios.post('/api/v1/settings/plex/sync', {
method: 'POST', cancel: true,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
cancel: true,
}),
}); });
if (!res.ok) throw new Error();
revalidateSync(); revalidateSync();
}; };
@@ -333,19 +313,15 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
.join(','); .join(',');
} }
const searchParams = new URLSearchParams(params.enable ? params : {}); await axios.get('/api/v1/settings/plex/library', {
const res = await fetch( params,
`/api/v1/settings/plex/library?${searchParams.toString()}` });
); } else {
if (!res.ok) throw new Error(); await axios.get('/api/v1/settings/plex/library', {
} else { params: {
const searchParams = new URLSearchParams({ enable: [...activeLibraries, libraryId].join(','),
enable: [...activeLibraries, libraryId].join(','), },
}); });
const res = await fetch(
`/api/v1/settings/plex/library?${searchParams.toString()}`
);
if (!res.ok) throw new Error();
} }
setIsSyncing(false); setIsSyncing(false);
revalidate(); revalidate();
@@ -409,19 +385,12 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
toastId = id; toastId = id;
} }
); );
const res = await fetch('/api/v1/settings/plex', { await axios.post('/api/v1/settings/plex', {
method: 'POST', ip: values.hostname,
headers: { port: Number(values.port),
'Content-Type': 'application/json', useSsl: values.useSsl,
}, webAppUrl: values.webAppUrl,
body: JSON.stringify({ } as PlexSettings);
ip: values.hostname,
port: Number(values.port),
useSsl: values.useSsl,
webAppUrl: values.webAppUrl,
} as PlexSettings),
});
if (!res.ok) throw new Error();
syncLibraries(); syncLibraries();
@@ -779,27 +748,14 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
validationSchema={TautulliSettingsSchema} validationSchema={TautulliSettingsSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
const res = await fetch('/api/v1/settings/tautulli', { await axios.post('/api/v1/settings/tautulli', {
method: 'POST', hostname: values.tautulliHostname,
headers: { port: Number(values.tautulliPort),
'Content-Type': 'application/json', useSsl: values.tautulliUseSsl,
}, urlBase: values.tautulliUrlBase,
body: JSON.stringify({ apiKey: values.tautulliApiKey,
hostname: values.tautulliHostname, externalUrl: values.tautulliExternalUrl,
port: Number(values.tautulliPort), } as TautulliSettings);
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( addToast(
intl.formatMessage(messages.toastTautulliSettingsSuccess), intl.formatMessage(messages.toastTautulliSettingsSuccess),

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