Compare commits
75 Commits
preview-fi
...
pr-934
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd18a9fe9a | ||
|
|
70aab8b0a9 | ||
|
|
1c176dc71e | ||
|
|
bd4da6d5fc | ||
|
|
12f908de7f | ||
|
|
c7d2386799 | ||
|
|
61dcd8e487 | ||
|
|
9aee8887d3 | ||
|
|
2348f23f43 | ||
|
|
74a2d25f15 | ||
|
|
a2c2d261fc | ||
|
|
71acfb1b1f | ||
|
|
29a32d0391 | ||
|
|
f7be4789a2 | ||
|
|
181cb19048 | ||
|
|
32c77f9e94 | ||
|
|
b43c1e350e | ||
|
|
64453320d3 | ||
|
|
36d98a2681 | ||
|
|
d5f817e734 | ||
|
|
422085523e | ||
|
|
fccfca6ed0 | ||
|
|
b04f280fbd | ||
|
|
3fc14c9e22 | ||
|
|
6b0909264d | ||
|
|
62dbde448c | ||
|
|
0116c13e06 | ||
|
|
c96ca6742e | ||
|
|
c80d9a853a | ||
|
|
6cea8bba59 | ||
|
|
2be9c7dcc1 | ||
|
|
5cc4389825 | ||
|
|
7923fc67e8 | ||
|
|
afed6e8879 | ||
|
|
341c7171cd | ||
|
|
3ab184be20 | ||
|
|
dd6dbf1de9 | ||
|
|
c600566ac0 | ||
|
|
4db1df2ba5 | ||
|
|
3a363ae1ff | ||
|
|
084e1b224e | ||
|
|
b36bb3fa58 | ||
|
|
7be97a0e12 | ||
|
|
78e6fdb7bf | ||
|
|
fc95e9da4b | ||
|
|
e6b5707190 | ||
|
|
1218876fbf | ||
|
|
b9336fc589 | ||
|
|
20762143db | ||
|
|
9d10efd277 | ||
|
|
ba81ed25e2 | ||
|
|
05a4adf369 | ||
|
|
1449883629 | ||
|
|
9f4329e243 | ||
|
|
5743105db4 | ||
|
|
fce8231710 | ||
|
|
53be59ad51 | ||
|
|
7b8adf5bdf | ||
|
|
7ee45ef6e1 | ||
|
|
50488db05a | ||
|
|
a132105b45 | ||
|
|
cb172cc737 | ||
|
|
cee3be2c64 | ||
|
|
8ecb459808 | ||
|
|
372b3649b5 | ||
|
|
ee472e11af | ||
|
|
1fd3a00284 | ||
|
|
c97c40e04c | ||
|
|
12f4d78692 | ||
|
|
18e3f140eb | ||
|
|
226e910728 | ||
|
|
fb6140972a | ||
|
|
9499b364af | ||
|
|
a15f0ec029 | ||
|
|
5c269368aa |
@@ -403,6 +403,42 @@
|
|||||||
"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
2
.github/CODEOWNERS
vendored
@@ -1,2 +1,2 @@
|
|||||||
# Global code ownership
|
# Global code ownership
|
||||||
* @Fallenbagel
|
* @Fallenbagel @gauthier-th
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ pnpm-lock.yaml
|
|||||||
# assets
|
# assets
|
||||||
src/assets/
|
src/assets/
|
||||||
public/
|
public/
|
||||||
|
docs/
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ 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: {
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -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-40-orange.svg"/></a>
|
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-47-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,6 +137,15 @@ 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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const testUser = {
|
const testUser = {
|
||||||
displayName: 'Test User',
|
username: '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('#displayName').type(testUser.displayName);
|
cy.get('#username').type(testUser.username);
|
||||||
cy.get('#email').type(testUser.emailAddress);
|
cy.get('#email').type(testUser.emailAddress);
|
||||||
cy.get('#password').type(testUser.password);
|
cy.get('#password').type(testUser.password);
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ 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
|
||||||
@@ -245,6 +248,7 @@ 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:
|
||||||
@@ -269,6 +273,7 @@ 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:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
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
|
||||||
|
|||||||
@@ -43,8 +43,6 @@
|
|||||||
"@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",
|
||||||
@@ -121,7 +119,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/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",
|
||||||
|
|||||||
12799
pnpm-lock.yaml
generated
12799
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import axios from 'axios';
|
import fs, { promises as fsp } from 'node:fs';
|
||||||
import fs, { promises as fsp } from 'fs';
|
import path from 'node:path';
|
||||||
import path from 'path';
|
import { Readable } from 'node:stream';
|
||||||
|
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
|
||||||
@@ -161,13 +162,18 @@ class AnimeListMapping {
|
|||||||
label: 'Anime-List Sync',
|
label: 'Anime-List Sync',
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(MAPPING_URL, {
|
const response = await fetch(MAPPING_URL);
|
||||||
responseType: 'stream',
|
if (!response.ok) {
|
||||||
});
|
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);
|
||||||
response.data.pipe(writer);
|
writer.on('error', reject);
|
||||||
|
if (!response.body) return reject();
|
||||||
|
Readable.fromWeb(response.body as ReadableStream<Uint8Array>).pipe(
|
||||||
|
writer
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Failed to download Anime-List mapping: ${e.message}`);
|
throw new Error(`Failed to download Anime-List mapping: ${e.message}`);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
|
import type { RateLimitOptions } from '@server/utils/rateLimit';
|
||||||
import axios from 'axios';
|
import rateLimit from '@server/utils/rateLimit';
|
||||||
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)
|
||||||
@@ -12,71 +11,87 @@ const DEFAULT_ROLLING_BUFFER = 10000;
|
|||||||
interface ExternalAPIOptions {
|
interface ExternalAPIOptions {
|
||||||
nodeCache?: NodeCache;
|
nodeCache?: NodeCache;
|
||||||
headers?: Record<string, unknown>;
|
headers?: Record<string, unknown>;
|
||||||
rateLimit?: {
|
rateLimit?: RateLimitOptions;
|
||||||
maxRPS: number;
|
|
||||||
maxRequests: number;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ExternalAPI {
|
class ExternalAPI {
|
||||||
protected axios: AxiosInstance;
|
protected fetch: typeof fetch;
|
||||||
|
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, unknown>,
|
params: Record<string, string> = {},
|
||||||
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.axios = rateLimit(this.axios, {
|
this.fetch = rateLimit(fetch, options.rateLimit);
|
||||||
maxRequests: options.rateLimit.maxRequests,
|
} else {
|
||||||
maxRPS: options.rateLimit.maxRPS,
|
this.fetch = fetch;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
config?: AxiosRequestConfig,
|
params?: Record<string, string>,
|
||||||
ttl?: number
|
ttl?: number,
|
||||||
|
config?: RequestInit
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, config?.params);
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
|
...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 response = await this.axios.get<T>(endpoint, config);
|
const url = this.formatUrl(endpoint, params);
|
||||||
|
const response = await this.fetch(url, {
|
||||||
|
...config,
|
||||||
|
headers: {
|
||||||
|
...this.defaultHeaders,
|
||||||
|
...config?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
||||||
|
{
|
||||||
|
cause: response,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
if (this.cache) {
|
if (this.cache) {
|
||||||
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async post<T>(
|
protected async post<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
data: Record<string, unknown>,
|
data?: Record<string, unknown>,
|
||||||
config?: AxiosRequestConfig,
|
params?: Record<string, string>,
|
||||||
ttl?: number
|
ttl?: number,
|
||||||
|
config?: RequestInit
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
config: config?.params,
|
config: { ...this.params, ...params },
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||||
@@ -84,21 +99,117 @@ class ExternalAPI {
|
|||||||
return cachedItem;
|
return cachedItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.axios.post<T>(endpoint, data, config);
|
const url = this.formatUrl(endpoint, params);
|
||||||
|
const response = await this.fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
...config,
|
||||||
|
headers: {
|
||||||
|
...this.defaultHeaders,
|
||||||
|
...config?.headers,
|
||||||
|
},
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
||||||
|
{
|
||||||
|
cause: response,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const resData = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
if (this.cache) {
|
if (this.cache) {
|
||||||
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data;
|
return resData;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async put<T>(
|
||||||
|
endpoint: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
params?: Record<string, string>,
|
||||||
|
ttl?: number,
|
||||||
|
config?: RequestInit
|
||||||
|
): Promise<T> {
|
||||||
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
|
config: { ...this.params, ...params },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||||
|
if (cachedItem) {
|
||||||
|
return cachedItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = this.formatUrl(endpoint, params);
|
||||||
|
const response = await this.fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
...config,
|
||||||
|
headers: {
|
||||||
|
...this.defaultHeaders,
|
||||||
|
...config?.headers,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
||||||
|
{
|
||||||
|
cause: response,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const resData = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
|
if (this.cache) {
|
||||||
|
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resData;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async delete<T>(
|
||||||
|
endpoint: string,
|
||||||
|
params?: Record<string, string>,
|
||||||
|
config?: RequestInit
|
||||||
|
): Promise<T> {
|
||||||
|
const url = this.formatUrl(endpoint, params);
|
||||||
|
const response = await this.fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
...config,
|
||||||
|
headers: {
|
||||||
|
...this.defaultHeaders,
|
||||||
|
...config?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
||||||
|
{
|
||||||
|
cause: response,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async getRolling<T>(
|
protected async getRolling<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
config?: AxiosRequestConfig,
|
params?: Record<string, string>,
|
||||||
ttl?: number
|
ttl?: number,
|
||||||
|
config?: RequestInit,
|
||||||
|
overwriteBaseUrl?: string
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, config?.params);
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
|
...this.params,
|
||||||
|
...params,
|
||||||
|
});
|
||||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||||
|
|
||||||
if (cachedItem) {
|
if (cachedItem) {
|
||||||
@@ -109,20 +220,78 @@ class ExternalAPI {
|
|||||||
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
|
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
|
||||||
Date.now() - DEFAULT_ROLLING_BUFFER
|
Date.now() - DEFAULT_ROLLING_BUFFER
|
||||||
) {
|
) {
|
||||||
this.axios.get<T>(endpoint, config).then((response) => {
|
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
|
||||||
this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
this.fetch(url, {
|
||||||
|
...config,
|
||||||
|
headers: {
|
||||||
|
...this.defaultHeaders,
|
||||||
|
...config?.headers,
|
||||||
|
},
|
||||||
|
}).then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`${response.status} ${response.statusText}${
|
||||||
|
text ? ': ' + text : ''
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
cause: response,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = await this.getDataFromResponse(response);
|
||||||
|
this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return cachedItem;
|
return cachedItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.axios.get<T>(endpoint, config);
|
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
|
||||||
|
const response = await this.fetch(url, {
|
||||||
|
...config,
|
||||||
|
headers: {
|
||||||
|
...this.defaultHeaders,
|
||||||
|
...config?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
||||||
|
{
|
||||||
|
cause: response,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
if (this.cache) {
|
if (this.cache) {
|
||||||
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data;
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatUrl(
|
||||||
|
endpoint: string,
|
||||||
|
params?: Record<string, string>,
|
||||||
|
overwriteBaseUrl?: string
|
||||||
|
): string {
|
||||||
|
const baseUrl = overwriteBaseUrl || this.baseUrl;
|
||||||
|
const href =
|
||||||
|
baseUrl +
|
||||||
|
(baseUrl.endsWith('/') ? '' : '/') +
|
||||||
|
(endpoint.startsWith('/') ? endpoint.slice(1) : endpoint);
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
...this.params,
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
href +
|
||||||
|
(searchParams.toString().length
|
||||||
|
? '?' + searchParams.toString()
|
||||||
|
: searchParams.toString())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private serializeCacheKey(
|
private serializeCacheKey(
|
||||||
@@ -135,6 +304,29 @@ class ExternalAPI {
|
|||||||
|
|
||||||
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
|
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getDataFromResponse(response: Response) {
|
||||||
|
const contentType = response.headers.get('Content-Type');
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
return await response.json();
|
||||||
|
} else if (
|
||||||
|
contentType?.includes('application/xml') ||
|
||||||
|
contentType?.includes('text/html') ||
|
||||||
|
contentType?.includes('text/plain')
|
||||||
|
) {
|
||||||
|
return await response.text();
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return await response.blob();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ExternalAPI;
|
export default ExternalAPI;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import ExternalAPI from './externalapi';
|
|
||||||
|
|
||||||
interface GitHubRelease {
|
interface GitHubRelease {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -67,10 +67,6 @@ 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,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -85,9 +81,7 @@ class GithubAPI extends ExternalAPI {
|
|||||||
const data = await this.get<GitHubRelease[]>(
|
const data = await this.get<GitHubRelease[]>(
|
||||||
'/repos/fallenbagel/jellyseerr/releases',
|
'/repos/fallenbagel/jellyseerr/releases',
|
||||||
{
|
{
|
||||||
params: {
|
per_page: take.toString(),
|
||||||
per_page: take,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -112,10 +106,8 @@ class GithubAPI extends ExternalAPI {
|
|||||||
const data = await this.get<GithubCommit[]>(
|
const data = await this.get<GithubCommit[]>(
|
||||||
'/repos/fallenbagel/jellyseerr/commits',
|
'/repos/fallenbagel/jellyseerr/commits',
|
||||||
{
|
{
|
||||||
params: {
|
per_page: take.toString(),
|
||||||
per_page: take,
|
branch,
|
||||||
branch,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -93,9 +93,7 @@ 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;
|
||||||
@@ -111,14 +109,9 @@ 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(
|
||||||
@@ -127,7 +120,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 =
|
const headers: { [key: string]: string } =
|
||||||
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
|
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
|
||||||
|
|
||||||
return this.post<JellyfinLoginResponse>(
|
return this.post<JellyfinLoginResponse>(
|
||||||
@@ -136,6 +129,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
Username,
|
Username,
|
||||||
Pw: Password,
|
Pw: Password,
|
||||||
},
|
},
|
||||||
|
{},
|
||||||
|
undefined,
|
||||||
{ headers }
|
{ headers }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -152,7 +147,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
try {
|
try {
|
||||||
return await authenticate(false);
|
return await authenticate(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const status = e.response?.status;
|
const status = e.cause?.status;
|
||||||
|
|
||||||
const networkErrorCodes = new Set([
|
const networkErrorCodes = new Set([
|
||||||
'ECONNREFUSED',
|
'ECONNREFUSED',
|
||||||
@@ -190,7 +185,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
|
|
||||||
return systemInfoResponse;
|
return systemInfoResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +202,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +217,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +233,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +291,16 @@ 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?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
|
`/Users/${this.userId}/Items`,
|
||||||
|
{
|
||||||
|
SortBy: 'SortName',
|
||||||
|
SortOrder: 'Ascending',
|
||||||
|
IncludeItemTypes: 'Series,Movie,Others',
|
||||||
|
Recursive: 'true',
|
||||||
|
StartIndex: '0',
|
||||||
|
ParentId: id,
|
||||||
|
collapseBoxSetItems: 'false',
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return libraryItemsResponse.Items.filter(
|
return libraryItemsResponse.Items.filter(
|
||||||
@@ -308,14 +312,18 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.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?Limit=12&ParentId=${id}`
|
`/Users/${this.userId}/Items/Latest`,
|
||||||
|
{
|
||||||
|
Limit: '12',
|
||||||
|
ParentId: id,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return itemResponse;
|
return itemResponse;
|
||||||
@@ -325,7 +333,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +348,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return itemResponse;
|
return itemResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (availabilitySync.running) {
|
if (availabilitySync.running) {
|
||||||
if (e.response && e.response.status === 500) {
|
if (e.cause?.status === 500) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +357,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.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,7 +372,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +382,10 @@ 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?seasonId=${seasonID}`
|
`/Shows/${seriesID}/Episodes`,
|
||||||
|
{
|
||||||
|
seasonId: seasonID,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return episodeResponse.Items.filter(
|
return episodeResponse.Items.filter(
|
||||||
@@ -386,6 +397,23 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +137,6 @@ 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,
|
||||||
}
|
}
|
||||||
@@ -149,15 +147,11 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
|
|
||||||
public async getDevices(): Promise<PlexDevice[]> {
|
public async getDevices(): Promise<PlexDevice[]> {
|
||||||
try {
|
try {
|
||||||
const devicesResp = await this.axios.get(
|
const devicesResp = await this.get('/api/resources', {
|
||||||
'/api/resources?includeHttps=1',
|
includeHttps: '1',
|
||||||
{
|
});
|
||||||
transformResponse: [],
|
|
||||||
responseType: 'text',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const parsedXml = await xml2js.parseStringPromise(
|
const parsedXml = await xml2js.parseStringPromise(
|
||||||
devicesResp.data as DeviceResponse
|
devicesResp as DeviceResponse
|
||||||
);
|
);
|
||||||
return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({
|
return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({
|
||||||
name: pxml.$.name,
|
name: pxml.$.name,
|
||||||
@@ -205,11 +199,11 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
|
|
||||||
public async getUser(): Promise<PlexUser> {
|
public async getUser(): Promise<PlexUser> {
|
||||||
try {
|
try {
|
||||||
const account = await this.axios.get<PlexAccountResponse>(
|
const account = await this.get<PlexAccountResponse>(
|
||||||
'/users/account.json'
|
'/users/account.json'
|
||||||
);
|
);
|
||||||
|
|
||||||
return account.data.user;
|
return account.user;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting the account from plex.tv: ${e.message}`,
|
`Something went wrong while getting the account from plex.tv: ${e.message}`,
|
||||||
@@ -249,13 +243,10 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getUsers(): Promise<UsersResponse> {
|
public async getUsers(): Promise<UsersResponse> {
|
||||||
const response = await this.axios.get('/api/users', {
|
const data = await this.get('/api/users');
|
||||||
transformResponse: [],
|
|
||||||
responseType: 'text',
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsedXml = (await xml2js.parseStringPromise(
|
const parsedXml = (await xml2js.parseStringPromise(
|
||||||
response.data
|
data as string
|
||||||
)) as UsersResponse;
|
)) as UsersResponse;
|
||||||
return parsedXml;
|
return parsedXml;
|
||||||
}
|
}
|
||||||
@@ -270,49 +261,49 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
items: PlexWatchlistItem[];
|
items: PlexWatchlistItem[];
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const response = await this.axios.get<WatchlistResponse>(
|
const params = new URLSearchParams({
|
||||||
'/library/sections/watchlist/all',
|
'X-Plex-Container-Start': offset.toString(),
|
||||||
|
'X-Plex-Container-Size': size.toString(),
|
||||||
|
});
|
||||||
|
const response = await this.fetch(
|
||||||
|
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
|
||||||
{
|
{
|
||||||
params: {
|
headers: this.defaultHeaders,
|
||||||
'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(
|
||||||
(response.data.MediaContainer.Metadata ?? []).map(
|
(data.MediaContainer.Metadata ?? []).map(async (watchlistItem) => {
|
||||||
async (watchlistItem) => {
|
const detailedResponse = await this.getRolling<MetadataResponse>(
|
||||||
const detailedResponse = await this.getRolling<MetadataResponse>(
|
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||||
`/library/metadata/${watchlistItem.ratingKey}`,
|
{},
|
||||||
{
|
undefined,
|
||||||
baseURL: 'https://metadata.provider.plex.tv',
|
{},
|
||||||
}
|
'https://metadata.provider.plex.tv'
|
||||||
);
|
);
|
||||||
|
|
||||||
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
||||||
|
|
||||||
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);
|
||||||
@@ -320,7 +311,7 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
return {
|
return {
|
||||||
offset,
|
offset,
|
||||||
size,
|
size,
|
||||||
totalSize: response.data.MediaContainer.totalSize,
|
totalSize: data.MediaContainer.totalSize,
|
||||||
items: filteredList,
|
items: filteredList,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ExternalAPI from './externalapi';
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
|
|
||||||
interface PushoverSoundsResponse {
|
interface PushoverSoundsResponse {
|
||||||
sounds: {
|
sounds: {
|
||||||
@@ -26,24 +26,13 @@ export const mapSounds = (sounds: {
|
|||||||
|
|
||||||
class PushoverAPI extends ExternalAPI {
|
class PushoverAPI extends ExternalAPI {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(
|
super('https://api.pushover.net/1');
|
||||||
'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', {
|
||||||
params: {
|
token: appToken,
|
||||||
token: appToken,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapSounds(data.sounds);
|
return mapSounds(data.sounds);
|
||||||
|
|||||||
@@ -155,13 +155,13 @@ export interface IMDBRating {
|
|||||||
*/
|
*/
|
||||||
class IMDBRadarrProxy extends ExternalAPI {
|
class IMDBRadarrProxy extends ExternalAPI {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('https://api.radarr.video/v1', {
|
super(
|
||||||
headers: {
|
'https://api.radarr.video/v1',
|
||||||
'Content-Type': 'application/json',
|
{},
|
||||||
Accept: 'application/json',
|
{
|
||||||
},
|
nodeCache: cacheManager.getCache('imdb').data,
|
||||||
nodeCache: cacheManager.getCache('imdb').data,
|
}
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,7 +175,11 @@ class IMDBRadarrProxy extends ExternalAPI {
|
|||||||
`/movie/imdb/${IMDBid}`
|
`/movie/imdb/${IMDBid}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!data?.length || data[0].ImdbId !== IMDBid) {
|
if (
|
||||||
|
!data?.length ||
|
||||||
|
data[0].ImdbId !== IMDBid ||
|
||||||
|
!data[0].MovieRatings.Imdb
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,15 +63,12 @@ class RottenTomatoes extends ExternalAPI {
|
|||||||
super(
|
super(
|
||||||
'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
|
'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
|
||||||
{
|
{
|
||||||
'x-algolia-agent':
|
'x-algolia-agent': 'Algolia for JavaScript (4.14.3); Browser (lite)',
|
||||||
'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)',
|
|
||||||
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
|
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
|
||||||
'x-algolia-application-id': '79FRDP12PN',
|
'x-algolia-application-id': '79FRDP12PN',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
'x-algolia-usertoken': settings.clientId,
|
'x-algolia-usertoken': settings.clientId,
|
||||||
},
|
},
|
||||||
nodeCache: cacheManager.getCache('rt').data,
|
nodeCache: cacheManager.getCache('rt').data,
|
||||||
|
|||||||
@@ -113,9 +113,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
|
|
||||||
public getSystemStatus = async (): Promise<SystemStatus> => {
|
public getSystemStatus = async (): Promise<SystemStatus> => {
|
||||||
try {
|
try {
|
||||||
const response = await this.axios.get<SystemStatus>('/system/status');
|
const data = await this.get<SystemStatus>('/system/status');
|
||||||
|
|
||||||
return response.data;
|
return 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,16 +157,11 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
|
|
||||||
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
||||||
try {
|
try {
|
||||||
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
|
const data = await this.get<QueueResponse<QueueItemAppendT>>(`/queue`, {
|
||||||
`/queue`,
|
includeEpisode: 'true',
|
||||||
{
|
});
|
||||||
params: {
|
|
||||||
includeEpisode: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.data.records;
|
return 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}`
|
||||||
@@ -176,9 +171,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
|
|
||||||
public getTags = async (): Promise<Tag[]> => {
|
public getTags = async (): Promise<Tag[]> => {
|
||||||
try {
|
try {
|
||||||
const response = await this.axios.get<Tag[]>(`/tag`);
|
const data = await this.get<Tag[]>(`/tag`);
|
||||||
|
|
||||||
return response.data;
|
return 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}`
|
||||||
@@ -188,11 +183,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 response = await this.axios.post<Tag>(`/tag`, {
|
const data = await this.post<Tag>(`/tag`, {
|
||||||
label,
|
label,
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data;
|
return 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}`);
|
||||||
}
|
}
|
||||||
@@ -203,7 +198,7 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
options: Record<string, unknown>
|
options: Record<string, unknown>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.axios.post(`/command`, {
|
await this.post(`/command`, {
|
||||||
name: commandName,
|
name: commandName,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
|
|
||||||
public getMovies = async (): Promise<RadarrMovie[]> => {
|
public getMovies = async (): Promise<RadarrMovie[]> => {
|
||||||
try {
|
try {
|
||||||
const response = await this.axios.get<RadarrMovie[]>('/movie');
|
const data = await this.get<RadarrMovie[]>('/movie');
|
||||||
|
|
||||||
return response.data;
|
return 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 response = await this.axios.get<RadarrMovie>(`/movie/${id}`);
|
const data = await this.get<RadarrMovie>(`/movie/${id}`);
|
||||||
|
|
||||||
return response.data;
|
return 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,17 +57,15 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
|
|
||||||
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
|
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
|
||||||
try {
|
try {
|
||||||
const response = await this.axios.get<RadarrMovie[]>('/movie/lookup', {
|
const data = await this.get<RadarrMovie[]>('/movie/lookup', {
|
||||||
params: {
|
term: `tmdb:${id}`,
|
||||||
term: `tmdb:${id}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.data[0]) {
|
if (!data[0]) {
|
||||||
throw new Error('Movie not found');
|
throw new Error('Movie not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data[0];
|
return 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',
|
||||||
@@ -97,7 +95,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 response = await this.axios.put<RadarrMovie>(`/movie`, {
|
const data = await this.put<RadarrMovie>(`/movie`, {
|
||||||
...movie,
|
...movie,
|
||||||
title: options.title,
|
title: options.title,
|
||||||
qualityProfileId: options.qualityProfileId,
|
qualityProfileId: options.qualityProfileId,
|
||||||
@@ -114,25 +112,25 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data.monitored) {
|
if (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: response.data.id,
|
movieId: data.id,
|
||||||
movieTitle: response.data.title,
|
movieTitle: data.title,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
logger.debug('Radarr update details', {
|
logger.debug('Radarr update details', {
|
||||||
label: 'Radarr',
|
label: 'Radarr',
|
||||||
movie: response.data,
|
movie: data,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.searchNow) {
|
if (options.searchNow) {
|
||||||
this.searchMovie(response.data.id);
|
this.searchMovie(data.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data;
|
return 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',
|
||||||
@@ -150,7 +148,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
return movie;
|
return movie;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.axios.post<RadarrMovie>(`/movie`, {
|
const data = await this.post<RadarrMovie>(`/movie`, {
|
||||||
title: options.title,
|
title: options.title,
|
||||||
qualityProfileId: options.qualityProfileId,
|
qualityProfileId: options.qualityProfileId,
|
||||||
profileId: options.profileId,
|
profileId: options.profileId,
|
||||||
@@ -166,11 +164,11 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data.id) {
|
if (data.id) {
|
||||||
logger.info('Radarr accepted request', { label: 'Radarr' });
|
logger.info('Radarr accepted request', { label: 'Radarr' });
|
||||||
logger.debug('Radarr add details', {
|
logger.debug('Radarr add details', {
|
||||||
label: 'Radarr',
|
label: 'Radarr',
|
||||||
movie: response.data,
|
movie: data,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.error('Failed to add movie to Radarr', {
|
logger.error('Failed to add movie to Radarr', {
|
||||||
@@ -179,15 +177,22 @@ 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 response.data;
|
return 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: e?.response?.data,
|
response: errorData,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
throw new Error('Failed to add movie to Radarr');
|
throw new Error('Failed to add movie to Radarr');
|
||||||
@@ -216,11 +221,9 @@ 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.axios.delete(`/movie/${id}`, {
|
await this.delete(`/movie/${id}`, {
|
||||||
params: {
|
deleteFiles: 'true',
|
||||||
deleteFiles: true,
|
addImportExclusion: 'false',
|
||||||
addImportExclusion: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
logger.info(`[Radarr] Removed movie ${title}`);
|
logger.info(`[Radarr] Removed movie ${title}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -117,9 +117,9 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
|
|
||||||
public async getSeries(): Promise<SonarrSeries[]> {
|
public async getSeries(): Promise<SonarrSeries[]> {
|
||||||
try {
|
try {
|
||||||
const response = await this.axios.get<SonarrSeries[]>('/series');
|
const data = await this.get<SonarrSeries[]>('/series');
|
||||||
|
|
||||||
return response.data;
|
return 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 response = await this.axios.get<SonarrSeries>(`/series/${id}`);
|
const data = await this.get<SonarrSeries>(`/series/${id}`);
|
||||||
|
|
||||||
return response.data;
|
return 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,17 +137,15 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
|
|
||||||
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
|
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
|
||||||
try {
|
try {
|
||||||
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
const data = await this.get<SonarrSeries[]>('/series/lookup', {
|
||||||
params: {
|
term: title,
|
||||||
term: title,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.data[0]) {
|
if (!data[0]) {
|
||||||
throw new Error('No series found');
|
throw new Error('No series found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data;
|
return 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',
|
||||||
@@ -160,17 +158,15 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
|
|
||||||
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
|
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
|
||||||
try {
|
try {
|
||||||
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
const data = await this.get<SonarrSeries[]>('/series/lookup', {
|
||||||
params: {
|
term: `tvdb:${id}`,
|
||||||
term: `tvdb:${id}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.data[0]) {
|
if (!data[0]) {
|
||||||
throw new Error('Series not found');
|
throw new Error('Series not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data[0];
|
return 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',
|
||||||
@@ -191,27 +187,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 newSeriesResponse = await this.axios.put<SonarrSeries>(
|
const newSeriesData = await this.put<SonarrSeries>(
|
||||||
'/series',
|
'/series',
|
||||||
series
|
series as any
|
||||||
);
|
);
|
||||||
|
|
||||||
if (newSeriesResponse.data.id) {
|
if (newSeriesData.id) {
|
||||||
logger.info('Updated existing series in Sonarr.', {
|
logger.info('Updated existing series in Sonarr.', {
|
||||||
label: 'Sonarr',
|
label: 'Sonarr',
|
||||||
seriesId: newSeriesResponse.data.id,
|
seriesId: newSeriesData.id,
|
||||||
seriesTitle: newSeriesResponse.data.title,
|
seriesTitle: newSeriesData.title,
|
||||||
});
|
});
|
||||||
logger.debug('Sonarr update details', {
|
logger.debug('Sonarr update details', {
|
||||||
label: 'Sonarr',
|
label: 'Sonarr',
|
||||||
movie: newSeriesResponse.data,
|
movie: newSeriesData,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.searchNow) {
|
if (options.searchNow) {
|
||||||
this.searchSeries(newSeriesResponse.data.id);
|
this.searchSeries(newSeriesData.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return newSeriesResponse.data;
|
return newSeriesData;
|
||||||
} else {
|
} else {
|
||||||
logger.error('Failed to update series in Sonarr', {
|
logger.error('Failed to update series in Sonarr', {
|
||||||
label: 'Sonarr',
|
label: 'Sonarr',
|
||||||
@@ -221,38 +217,35 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdSeriesResponse = await this.axios.post<SonarrSeries>(
|
const createdSeriesData = await this.post<SonarrSeries>('/series', {
|
||||||
'/series',
|
tvdbId: options.tvdbid,
|
||||||
{
|
title: options.title,
|
||||||
tvdbId: options.tvdbid,
|
qualityProfileId: options.profileId,
|
||||||
title: options.title,
|
languageProfileId: options.languageProfileId,
|
||||||
qualityProfileId: options.profileId,
|
seasons: this.buildSeasonList(
|
||||||
languageProfileId: options.languageProfileId,
|
options.seasons,
|
||||||
seasons: this.buildSeasonList(
|
series.seasons.map((season) => ({
|
||||||
options.seasons,
|
seasonNumber: season.seasonNumber,
|
||||||
series.seasons.map((season) => ({
|
// We force all seasons to false if its the first request
|
||||||
seasonNumber: season.seasonNumber,
|
monitored: false,
|
||||||
// We force all seasons to false if its the first request
|
}))
|
||||||
monitored: false,
|
),
|
||||||
}))
|
tags: options.tags,
|
||||||
),
|
seasonFolder: options.seasonFolder,
|
||||||
tags: options.tags,
|
monitored: options.monitored,
|
||||||
seasonFolder: options.seasonFolder,
|
rootFolderPath: options.rootFolderPath,
|
||||||
monitored: options.monitored,
|
seriesType: options.seriesType,
|
||||||
rootFolderPath: options.rootFolderPath,
|
addOptions: {
|
||||||
seriesType: options.seriesType,
|
ignoreEpisodesWithFiles: true,
|
||||||
addOptions: {
|
searchForMissingEpisodes: options.searchNow,
|
||||||
ignoreEpisodesWithFiles: true,
|
},
|
||||||
searchForMissingEpisodes: options.searchNow,
|
} as Partial<SonarrSeries>);
|
||||||
},
|
|
||||||
} as Partial<SonarrSeries>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (createdSeriesResponse.data.id) {
|
if (createdSeriesData.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: createdSeriesResponse.data,
|
movie: createdSeriesData,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.error('Failed to add movie to Sonarr', {
|
logger.error('Failed to add movie to Sonarr', {
|
||||||
@@ -262,13 +255,20 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
throw new Error('Failed to add series to Sonarr');
|
throw new Error('Failed to add series to Sonarr');
|
||||||
}
|
}
|
||||||
|
|
||||||
return createdSeriesResponse.data;
|
return createdSeriesData;
|
||||||
} 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: e?.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
throw new Error('Failed to add series');
|
throw new Error('Failed to add series');
|
||||||
}
|
}
|
||||||
@@ -340,14 +340,13 @@ 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.axios.delete(`/series/${id}`, {
|
await this.delete(`/series/${id}`, {
|
||||||
params: {
|
deleteFiles: 'true',
|
||||||
deleteFiles: true,
|
addImportExclusion: 'false',
|
||||||
addImportExclusion: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
logger.info(`[Radarr] Removed serie ${title}`);
|
logger.info(`[Radarr] Removed serie ${title}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
|
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 {
|
||||||
@@ -113,25 +112,25 @@ interface TautulliInfoResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class TautulliAPI {
|
class TautulliAPI extends ExternalAPI {
|
||||||
private axios: AxiosInstance;
|
|
||||||
|
|
||||||
constructor(settings: TautulliSettings) {
|
constructor(settings: TautulliSettings) {
|
||||||
this.axios = axios.create({
|
super(
|
||||||
baseURL: `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
|
`${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.axios.get<TautulliInfoResponse>('/api/v2', {
|
await this.get<TautulliInfoResponse>('/api/v2', {
|
||||||
params: { cmd: 'get_tautulli_info' },
|
cmd: 'get_tautulli_info',
|
||||||
})
|
})
|
||||||
).data.response.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',
|
||||||
@@ -148,14 +147,12 @@ class TautulliAPI {
|
|||||||
): Promise<TautulliWatchStats[]> {
|
): Promise<TautulliWatchStats[]> {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
|
await this.get<TautulliWatchStatsResponse>('/api/v2', {
|
||||||
params: {
|
cmd: 'get_item_watch_time_stats',
|
||||||
cmd: 'get_item_watch_time_stats',
|
rating_key: ratingKey,
|
||||||
rating_key: ratingKey,
|
grouping: '1',
|
||||||
grouping: 1,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
).data.response.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',
|
||||||
@@ -176,14 +173,12 @@ class TautulliAPI {
|
|||||||
): Promise<TautulliWatchUser[]> {
|
): Promise<TautulliWatchUser[]> {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
await this.axios.get<TautulliWatchUsersResponse>('/api/v2', {
|
await this.get<TautulliWatchUsersResponse>('/api/v2', {
|
||||||
params: {
|
cmd: 'get_item_user_stats',
|
||||||
cmd: 'get_item_user_stats',
|
rating_key: ratingKey,
|
||||||
rating_key: ratingKey,
|
grouping: '1',
|
||||||
grouping: 1,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
).data.response.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',
|
||||||
@@ -206,15 +201,13 @@ class TautulliAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
|
await this.get<TautulliWatchStatsResponse>('/api/v2', {
|
||||||
params: {
|
cmd: 'get_user_watch_time_stats',
|
||||||
cmd: 'get_user_watch_time_stats',
|
user_id: user.plexId.toString(),
|
||||||
user_id: user.plexId,
|
query_days: '0',
|
||||||
query_days: 0,
|
grouping: '1',
|
||||||
grouping: 1,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
).data.response.data[0];
|
).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',
|
||||||
@@ -245,19 +238,17 @@ class TautulliAPI {
|
|||||||
|
|
||||||
while (results.length < 20) {
|
while (results.length < 20) {
|
||||||
const tautulliData = (
|
const tautulliData = (
|
||||||
await this.axios.get<TautulliHistoryResponse>('/api/v2', {
|
await this.get<TautulliHistoryResponse>('/api/v2', {
|
||||||
params: {
|
cmd: 'get_history',
|
||||||
cmd: 'get_history',
|
grouping: '1',
|
||||||
grouping: 1,
|
order_column: 'date',
|
||||||
order_column: 'date',
|
order_dir: 'desc',
|
||||||
order_dir: 'desc',
|
user_id: user.plexId.toString(),
|
||||||
user_id: user.plexId,
|
media_type: 'movie,episode',
|
||||||
media_type: 'movie,episode',
|
length: take.toString(),
|
||||||
length: take,
|
start: start.toString(),
|
||||||
start,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
).data.response.data.data;
|
).response.data.data;
|
||||||
|
|
||||||
if (!tautulliData.length) {
|
if (!tautulliData.length) {
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -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,7 +129,10 @@ 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', {
|
||||||
params: { query, page, include_adult: includeAdult, language },
|
query,
|
||||||
|
page: page.toString(),
|
||||||
|
include_adult: includeAdult ? 'true' : 'false',
|
||||||
|
language,
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -152,13 +155,11 @@ 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', {
|
||||||
params: {
|
query,
|
||||||
query,
|
page: page.toString(),
|
||||||
page,
|
include_adult: includeAdult ? 'true' : 'false',
|
||||||
include_adult: includeAdult,
|
language,
|
||||||
language,
|
primary_release_year: year?.toString() || '',
|
||||||
primary_release_year: year,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -181,13 +182,11 @@ 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', {
|
||||||
params: {
|
query,
|
||||||
query,
|
page: page.toString(),
|
||||||
page,
|
include_adult: includeAdult ? 'true' : 'false',
|
||||||
include_adult: includeAdult,
|
language,
|
||||||
language,
|
first_air_date_year: year?.toString() || '',
|
||||||
first_air_date_year: year,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -210,7 +209,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}`, {
|
||||||
params: { language },
|
language,
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -230,7 +229,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`,
|
||||||
{
|
{
|
||||||
params: { language },
|
language,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -253,11 +252,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbMovieDetails>(
|
const data = await this.get<TmdbMovieDetails>(
|
||||||
`/movie/${movieId}`,
|
`/movie/${movieId}`,
|
||||||
{
|
{
|
||||||
params: {
|
language,
|
||||||
language,
|
append_to_response:
|
||||||
append_to_response:
|
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
||||||
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
43200
|
43200
|
||||||
);
|
);
|
||||||
@@ -279,11 +276,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbTvDetails>(
|
const data = await this.get<TmdbTvDetails>(
|
||||||
`/tv/${tvId}`,
|
`/tv/${tvId}`,
|
||||||
{
|
{
|
||||||
params: {
|
language,
|
||||||
language,
|
append_to_response:
|
||||||
append_to_response:
|
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
|
||||||
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
43200
|
43200
|
||||||
);
|
);
|
||||||
@@ -307,10 +302,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSeasonWithEpisodes>(
|
const data = await this.get<TmdbSeasonWithEpisodes>(
|
||||||
`/tv/${tvId}/season/${seasonNumber}`,
|
`/tv/${tvId}/season/${seasonNumber}`,
|
||||||
{
|
{
|
||||||
params: {
|
language: language || '',
|
||||||
language,
|
append_to_response: 'external_ids',
|
||||||
append_to_response: 'external_ids',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -333,10 +326,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchMovieResponse>(
|
const data = await this.get<TmdbSearchMovieResponse>(
|
||||||
`/movie/${movieId}/recommendations`,
|
`/movie/${movieId}/recommendations`,
|
||||||
{
|
{
|
||||||
params: {
|
page: page.toString(),
|
||||||
page,
|
language,
|
||||||
language,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -359,10 +350,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchMovieResponse>(
|
const data = await this.get<TmdbSearchMovieResponse>(
|
||||||
`/movie/${movieId}/similar`,
|
`/movie/${movieId}/similar`,
|
||||||
{
|
{
|
||||||
params: {
|
page: page.toString(),
|
||||||
page,
|
language,
|
||||||
language,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -385,10 +374,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchMovieResponse>(
|
const data = await this.get<TmdbSearchMovieResponse>(
|
||||||
`/keyword/${keywordId}/movies`,
|
`/keyword/${keywordId}/movies`,
|
||||||
{
|
{
|
||||||
params: {
|
page: page.toString(),
|
||||||
page,
|
language,
|
||||||
language,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -411,10 +398,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchTvResponse>(
|
const data = await this.get<TmdbSearchTvResponse>(
|
||||||
`/tv/${tvId}/recommendations`,
|
`/tv/${tvId}/recommendations`,
|
||||||
{
|
{
|
||||||
params: {
|
page: page.toString(),
|
||||||
page,
|
language,
|
||||||
language,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -437,10 +422,8 @@ 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`, {
|
||||||
params: {
|
page: page.toString(),
|
||||||
page,
|
language,
|
||||||
language,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -481,40 +464,38 @@ 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', {
|
||||||
params: {
|
sort_by: sortBy,
|
||||||
sort_by: sortBy,
|
page: page.toString(),
|
||||||
page,
|
include_adult: includeAdult ? 'true' : 'false',
|
||||||
include_adult: includeAdult,
|
language,
|
||||||
language,
|
region: this.region || '',
|
||||||
region: this.region,
|
with_original_language:
|
||||||
with_original_language:
|
originalLanguage && originalLanguage !== 'all'
|
||||||
originalLanguage && originalLanguage !== 'all'
|
? originalLanguage
|
||||||
? originalLanguage
|
: originalLanguage === 'all'
|
||||||
: originalLanguage === 'all'
|
? ''
|
||||||
? undefined
|
: this.originalLanguage || '',
|
||||||
: this.originalLanguage,
|
// Set our release date values, but check if one is set and not the other,
|
||||||
// Set our release date values, but check if one is set and not the other,
|
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||||
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
'primary_release_date.gte':
|
||||||
'primary_release_date.gte':
|
!primaryReleaseDateGte && primaryReleaseDateLte
|
||||||
!primaryReleaseDateGte && primaryReleaseDateLte
|
? defaultPastDate
|
||||||
? defaultPastDate
|
: primaryReleaseDateGte || '',
|
||||||
: primaryReleaseDateGte,
|
'primary_release_date.lte':
|
||||||
'primary_release_date.lte':
|
!primaryReleaseDateLte && primaryReleaseDateGte
|
||||||
!primaryReleaseDateLte && primaryReleaseDateGte
|
? defaultFutureDate
|
||||||
? defaultFutureDate
|
: primaryReleaseDateLte || '',
|
||||||
: primaryReleaseDateLte,
|
with_genres: genre || '',
|
||||||
with_genres: genre,
|
with_companies: studio || '',
|
||||||
with_companies: studio,
|
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,
|
watch_region: watchRegion || '',
|
||||||
watch_region: watchRegion,
|
with_watch_providers: watchProviders || '',
|
||||||
with_watch_providers: watchProviders,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -555,40 +536,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', {
|
||||||
params: {
|
sort_by: sortBy,
|
||||||
sort_by: sortBy,
|
page: page.toString(),
|
||||||
page,
|
language,
|
||||||
language,
|
region: this.region || '',
|
||||||
region: this.region,
|
// Set our release date values, but check if one is set and not the other,
|
||||||
// Set our release date values, but check if one is set and not the other,
|
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||||
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
'first_air_date.gte':
|
||||||
'first_air_date.gte':
|
!firstAirDateGte && firstAirDateLte
|
||||||
!firstAirDateGte && firstAirDateLte
|
? defaultPastDate
|
||||||
? defaultPastDate
|
: firstAirDateGte || '',
|
||||||
: firstAirDateGte,
|
'first_air_date.lte':
|
||||||
'first_air_date.lte':
|
!firstAirDateLte && firstAirDateGte
|
||||||
!firstAirDateLte && firstAirDateGte
|
? defaultFutureDate
|
||||||
? defaultFutureDate
|
: firstAirDateLte || '',
|
||||||
: firstAirDateLte,
|
with_original_language:
|
||||||
with_original_language:
|
originalLanguage && originalLanguage !== 'all'
|
||||||
originalLanguage && originalLanguage !== 'all'
|
? originalLanguage
|
||||||
? originalLanguage
|
: originalLanguage === 'all'
|
||||||
: originalLanguage === 'all'
|
? ''
|
||||||
? undefined
|
: this.originalLanguage || '',
|
||||||
: this.originalLanguage,
|
include_null_first_air_dates: includeEmptyReleaseDate
|
||||||
include_null_first_air_dates: includeEmptyReleaseDate,
|
? 'true'
|
||||||
with_genres: genre,
|
: 'false',
|
||||||
with_networks: network,
|
with_genres: genre || '',
|
||||||
with_keywords: keywords,
|
with_networks: network?.toString() || '',
|
||||||
'with_runtime.gte': withRuntimeGte,
|
with_keywords: keywords || '',
|
||||||
'with_runtime.lte': withRuntimeLte,
|
'with_runtime.gte': withRuntimeGte || '',
|
||||||
'vote_average.gte': voteAverageGte,
|
'with_runtime.lte': withRuntimeLte || '',
|
||||||
'vote_average.lte': voteAverageLte,
|
'vote_average.gte': voteAverageGte || '',
|
||||||
'vote_count.gte': voteCountGte,
|
'vote_average.lte': voteAverageLte || '',
|
||||||
'vote_count.lte': voteCountLte,
|
'vote_count.gte': voteCountGte || '',
|
||||||
with_watch_providers: watchProviders,
|
'vote_count.lte': voteCountLte || '',
|
||||||
watch_region: watchRegion,
|
with_watch_providers: watchProviders || '',
|
||||||
},
|
watch_region: watchRegion || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -608,12 +589,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbUpcomingMoviesResponse>(
|
const data = await this.get<TmdbUpcomingMoviesResponse>(
|
||||||
'/movie/upcoming',
|
'/movie/upcoming',
|
||||||
{
|
{
|
||||||
params: {
|
page: page.toString(),
|
||||||
page,
|
language,
|
||||||
language,
|
region: this.region || '',
|
||||||
region: this.region,
|
originalLanguage: this.originalLanguage || '',
|
||||||
originalLanguage: this.originalLanguage,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -636,11 +615,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchMultiResponse>(
|
const data = await this.get<TmdbSearchMultiResponse>(
|
||||||
`/trending/all/${timeWindow}`,
|
`/trending/all/${timeWindow}`,
|
||||||
{
|
{
|
||||||
params: {
|
page: page.toString(),
|
||||||
page,
|
language,
|
||||||
language,
|
region: this.region || '',
|
||||||
region: this.region,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -661,9 +638,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchMovieResponse>(
|
const data = await this.get<TmdbSearchMovieResponse>(
|
||||||
`/trending/movie/${timeWindow}`,
|
`/trending/movie/${timeWindow}`,
|
||||||
{
|
{
|
||||||
params: {
|
page: page.toString(),
|
||||||
page,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -684,9 +659,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchTvResponse>(
|
const data = await this.get<TmdbSearchTvResponse>(
|
||||||
`/trending/tv/${timeWindow}`,
|
`/trending/tv/${timeWindow}`,
|
||||||
{
|
{
|
||||||
params: {
|
page: page.toString(),
|
||||||
page,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -715,10 +688,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbExternalIdResponse>(
|
const data = await this.get<TmdbExternalIdResponse>(
|
||||||
`/find/${externalId}`,
|
`/find/${externalId}`,
|
||||||
{
|
{
|
||||||
params: {
|
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
|
||||||
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
|
language,
|
||||||
language,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -808,9 +779,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbCollection>(
|
const data = await this.get<TmdbCollection>(
|
||||||
`/collection/${collectionId}`,
|
`/collection/${collectionId}`,
|
||||||
{
|
{
|
||||||
params: {
|
language,
|
||||||
language,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -883,9 +852,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbGenresResult>(
|
const data = await this.get<TmdbGenresResult>(
|
||||||
'/genre/movie/list',
|
'/genre/movie/list',
|
||||||
{
|
{
|
||||||
params: {
|
language,
|
||||||
language,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -897,9 +864,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const englishData = await this.get<TmdbGenresResult>(
|
const englishData = await this.get<TmdbGenresResult>(
|
||||||
'/genre/movie/list',
|
'/genre/movie/list',
|
||||||
{
|
{
|
||||||
params: {
|
language: 'en',
|
||||||
language: 'en',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -934,9 +899,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbGenresResult>(
|
const data = await this.get<TmdbGenresResult>(
|
||||||
'/genre/tv/list',
|
'/genre/tv/list',
|
||||||
{
|
{
|
||||||
params: {
|
language,
|
||||||
language,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -948,9 +911,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const englishData = await this.get<TmdbGenresResult>(
|
const englishData = await this.get<TmdbGenresResult>(
|
||||||
'/genre/tv/list',
|
'/genre/tv/list',
|
||||||
{
|
{
|
||||||
params: {
|
language: 'en',
|
||||||
language: 'en',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -1005,10 +966,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbKeywordSearchResponse>(
|
const data = await this.get<TmdbKeywordSearchResponse>(
|
||||||
'/search/keyword',
|
'/search/keyword',
|
||||||
{
|
{
|
||||||
params: {
|
query,
|
||||||
query,
|
page: page.toString(),
|
||||||
page,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -1030,10 +989,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbCompanySearchResponse>(
|
const data = await this.get<TmdbCompanySearchResponse>(
|
||||||
'/search/company',
|
'/search/company',
|
||||||
{
|
{
|
||||||
params: {
|
query,
|
||||||
query,
|
page: page.toString(),
|
||||||
page,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -1053,9 +1010,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
|
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
|
||||||
'/watch/providers/regions',
|
'/watch/providers/regions',
|
||||||
{
|
{
|
||||||
params: {
|
language: language ? this.originalLanguage || '' : '',
|
||||||
language: language ?? this.originalLanguage,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -1079,10 +1034,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||||
'/watch/providers/movie',
|
'/watch/providers/movie',
|
||||||
{
|
{
|
||||||
params: {
|
language: language ? this.originalLanguage || '' : '',
|
||||||
language: language ?? this.originalLanguage,
|
watch_region: watchRegion,
|
||||||
watch_region: watchRegion,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -1106,10 +1059,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||||
'/watch/providers/tv',
|
'/watch/providers/tv',
|
||||||
{
|
{
|
||||||
params: {
|
language: language ? this.originalLanguage || '' : '',
|
||||||
language: language ?? this.originalLanguage,
|
watch_region: watchRegion,
|
||||||
watch_region: watchRegion,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,10 +32,17 @@ 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()}`);
|
||||||
@@ -56,7 +63,7 @@ app
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load Settings
|
// Load Settings
|
||||||
const settings = getSettings().load();
|
const settings = await getSettings().load();
|
||||||
restartFlag.initializeSettings(settings.main);
|
restartFlag.initializeSettings(settings.main);
|
||||||
|
|
||||||
// Migrate library types
|
// Migrate library types
|
||||||
|
|||||||
@@ -8,3 +8,16 @@ 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>>;
|
||||||
|
|||||||
@@ -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 { PaginatedResponse } from './common';
|
import type { NonFunctionProperties, PaginatedResponse } from './common';
|
||||||
|
|
||||||
export interface RequestResultsResponse extends PaginatedResponse {
|
export interface RequestResultsResponse extends PaginatedResponse {
|
||||||
results: MediaRequest[];
|
results: NonFunctionProperties<MediaRequest>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MediaRequestBody = {
|
export type MediaRequestBody = {
|
||||||
@@ -14,6 +14,7 @@ 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;
|
||||||
|
|||||||
@@ -63,12 +63,7 @@ class AvailabilitySync {
|
|||||||
) {
|
) {
|
||||||
admin = await userRepository.findOne({
|
admin = await userRepository.findOne({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
select: [
|
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
||||||
'id',
|
|
||||||
'jellyfinAuthToken',
|
|
||||||
'jellyfinUserId',
|
|
||||||
'jellyfinDeviceId',
|
|
||||||
],
|
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -86,7 +81,7 @@ class AvailabilitySync {
|
|||||||
if (admin) {
|
if (admin) {
|
||||||
this.jellyfinClient = new JellyfinAPI(
|
this.jellyfinClient = new JellyfinAPI(
|
||||||
getHostname(),
|
getHostname(),
|
||||||
admin.jellyfinAuthToken,
|
settings.jellyfin.apiKey,
|
||||||
admin.jellyfinDeviceId
|
admin.jellyfinDeviceId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import axios from 'axios';
|
import type { RateLimitOptions } from '@server/utils/rateLimit';
|
||||||
import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
|
import rateLimit from '@server/utils/rateLimit';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { promises } from 'fs';
|
import { promises } from 'fs';
|
||||||
import path, { join } from 'path';
|
import path, { join } from 'path';
|
||||||
@@ -98,26 +98,29 @@ class ImageProxy {
|
|||||||
return files.length;
|
return files.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
private axios;
|
private fetch: typeof fetch;
|
||||||
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.axios = rateLimit(this.axios, options.rateLimitOptions);
|
this.fetch = rateLimit(fetch, {
|
||||||
|
...options.rateLimitOptions,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.fetch = fetch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,17 +185,20 @@ class ImageProxy {
|
|||||||
): Promise<ImageResponse | null> {
|
): Promise<ImageResponse | null> {
|
||||||
try {
|
try {
|
||||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||||
const response = await this.axios.get(path, {
|
const href =
|
||||||
responseType: 'arraybuffer',
|
this.baseUrl +
|
||||||
});
|
(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 buffer = Buffer.from(response.data, 'binary');
|
|
||||||
const extension = path.split('.').pop() ?? '';
|
const extension = path.split('.').pop() ?? '';
|
||||||
const maxAge = Number(
|
const maxAge = Number(
|
||||||
(response.headers['cache-control'] ?? '0').split('=')[1]
|
(response.headers.get('cache-control') ?? '0').split('=')[1]
|
||||||
);
|
);
|
||||||
const expireAt = Date.now() + maxAge * 1000;
|
const expireAt = Date.now() + maxAge * 1000;
|
||||||
const etag = (response.headers.etag ?? '').replace(/"/g, '');
|
const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
|
||||||
|
|
||||||
await this.writeToCacheDir(
|
await this.writeToCacheDir(
|
||||||
directory,
|
directory,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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,
|
||||||
@@ -292,23 +291,39 @@ class DiscordAgent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await axios.post(settings.options.webhookUrl, {
|
const response = await fetch(settings.options.webhookUrl, {
|
||||||
username: settings.options.botUsername
|
method: 'POST',
|
||||||
? settings.options.botUsername
|
headers: {
|
||||||
: getSettings().main.applicationTitle,
|
'Content-Type': 'application/json',
|
||||||
avatar_url: settings.options.botAvatarUrl,
|
},
|
||||||
embeds: [this.buildEmbed(type, payload)],
|
body: JSON.stringify({
|
||||||
content: userMentions.join(' '),
|
username: settings.options.botUsername
|
||||||
} as DiscordWebhookPayload);
|
? settings.options.botUsername
|
||||||
|
: getSettings().main.applicationTitle,
|
||||||
|
avatar_url: settings.options.botAvatarUrl,
|
||||||
|
embeds: [this.buildEmbed(type, payload)],
|
||||||
|
content: userMentions.join(' '),
|
||||||
|
} as DiscordWebhookPayload),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
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: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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';
|
||||||
@@ -133,16 +132,32 @@ 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);
|
||||||
|
|
||||||
await axios.post(endpoint, notificationPayload);
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(notificationPayload),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
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: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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';
|
||||||
@@ -101,28 +100,39 @@ class LunaSeaAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(
|
const response = await fetch(settings.options.webhookUrl, {
|
||||||
settings.options.webhookUrl,
|
method: 'POST',
|
||||||
this.buildPayload(type, payload),
|
headers: settings.options.profileName
|
||||||
settings.options.profileName
|
|
||||||
? {
|
? {
|
||||||
headers: {
|
'Content-Type': 'application/json',
|
||||||
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: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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,
|
||||||
@@ -123,22 +122,34 @@ class PushbulletAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(
|
const response = await fetch(endpoint, {
|
||||||
endpoint,
|
method: 'POST',
|
||||||
{ ...notificationPayload, channel_tag: settings.options.channelTag },
|
headers: {
|
||||||
{
|
'Content-Type': 'application/json',
|
||||||
headers: {
|
'Access-Token': settings.options.accessToken,
|
||||||
'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: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -163,19 +174,32 @@ class PushbulletAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(endpoint, notificationPayload, {
|
const response = await fetch(endpoint, {
|
||||||
|
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: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -211,19 +235,32 @@ class PushbulletAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(endpoint, notificationPayload, {
|
const response = await fetch(endpoint, {
|
||||||
|
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: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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,
|
||||||
@@ -52,12 +51,15 @@ class PushoverAgent
|
|||||||
imageUrl: string
|
imageUrl: string
|
||||||
): Promise<Partial<PushoverImagePayload>> {
|
): Promise<Partial<PushoverImagePayload>> {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(imageUrl, {
|
const response = await fetch(imageUrl);
|
||||||
responseType: 'arraybuffer',
|
if (!response.ok) {
|
||||||
});
|
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['Content-Type'] || response.headers['content-type']
|
response.headers.get('Content-Type') ||
|
||||||
|
response.headers.get('content-type')
|
||||||
)?.toString();
|
)?.toString();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -65,10 +67,17 @@ 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: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -201,19 +210,35 @@ class PushoverAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
...notificationPayload,
|
method: 'POST',
|
||||||
token: settings.options.accessToken,
|
headers: {
|
||||||
user: settings.options.userToken,
|
'Content-Type': 'application/json',
|
||||||
sound: settings.options.sound,
|
},
|
||||||
} as PushoverPayload);
|
body: JSON.stringify({
|
||||||
|
...notificationPayload,
|
||||||
|
token: settings.options.accessToken,
|
||||||
|
user: settings.options.userToken,
|
||||||
|
sound: settings.options.sound,
|
||||||
|
} as PushoverPayload),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
} catch (e) {
|
} 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: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -241,20 +266,36 @@ class PushoverAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
...notificationPayload,
|
method: 'POST',
|
||||||
token: payload.notifyUser.settings.pushoverApplicationToken,
|
headers: {
|
||||||
user: payload.notifyUser.settings.pushoverUserKey,
|
'Content-Type': 'application/json',
|
||||||
sound: payload.notifyUser.settings.pushoverSound,
|
},
|
||||||
} as PushoverPayload);
|
body: JSON.stringify({
|
||||||
|
...notificationPayload,
|
||||||
|
token: payload.notifyUser.settings.pushoverApplicationToken,
|
||||||
|
user: payload.notifyUser.settings.pushoverUserKey,
|
||||||
|
sound: payload.notifyUser.settings.pushoverSound,
|
||||||
|
} as PushoverPayload),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
} catch (e) {
|
} 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: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -291,19 +332,35 @@ class PushoverAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
...notificationPayload,
|
method: 'POST',
|
||||||
token: user.settings.pushoverApplicationToken,
|
headers: {
|
||||||
user: user.settings.pushoverUserKey,
|
'Content-Type': 'application/json',
|
||||||
} 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: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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';
|
||||||
@@ -238,19 +237,32 @@ class SlackAgent
|
|||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await axios.post(
|
const response = await fetch(settings.options.webhookUrl, {
|
||||||
settings.options.webhookUrl,
|
method: 'POST',
|
||||||
this.buildEmbed(type, payload)
|
headers: {
|
||||||
);
|
'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: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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,
|
||||||
@@ -175,18 +174,34 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
...notificationPayload,
|
method: 'POST',
|
||||||
chat_id: settings.options.chatId,
|
headers: {
|
||||||
disable_notification: !!settings.options.sendSilently,
|
'Content-Type': 'application/json',
|
||||||
} 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: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -210,20 +225,36 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
...notificationPayload,
|
method: 'POST',
|
||||||
chat_id: payload.notifyUser.settings.telegramChatId,
|
headers: {
|
||||||
disable_notification:
|
'Content-Type': 'application/json',
|
||||||
!!payload.notifyUser.settings.telegramSendSilently,
|
},
|
||||||
} as TelegramMessagePayload | TelegramPhotoPayload);
|
body: JSON.stringify({
|
||||||
|
...notificationPayload,
|
||||||
|
chat_id: payload.notifyUser.settings.telegramChatId,
|
||||||
|
disable_notification:
|
||||||
|
!!payload.notifyUser.settings.telegramSendSilently,
|
||||||
|
} as TelegramMessagePayload | TelegramPhotoPayload),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
} catch (e) {
|
} 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: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -257,19 +288,35 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
...notificationPayload,
|
method: 'POST',
|
||||||
chat_id: user.settings.telegramChatId,
|
headers: {
|
||||||
disable_notification: !!user.settings?.telegramSendSilently,
|
'Content-Type': 'application/json',
|
||||||
} 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: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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';
|
||||||
@@ -178,26 +177,35 @@ class WebhookAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(
|
const response = await fetch(settings.options.webhookUrl, {
|
||||||
settings.options.webhookUrl,
|
method: 'POST',
|
||||||
this.buildPayload(type, payload),
|
headers: {
|
||||||
settings.options.authHeader
|
'Content-Type': 'application/json',
|
||||||
? {
|
...(settings.options.authHeader
|
||||||
headers: {
|
? { Authorization: settings.options.authHeader }
|
||||||
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: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -582,12 +582,7 @@ 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: [
|
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
||||||
'id',
|
|
||||||
'jellyfinAuthToken',
|
|
||||||
'jellyfinUserId',
|
|
||||||
'jellyfinDeviceId',
|
|
||||||
],
|
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -595,11 +590,9 @@ 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(
|
||||||
hostname,
|
getHostname(),
|
||||||
admin.jellyfinAuthToken,
|
settings.jellyfin.apiKey,
|
||||||
admin.jellyfinDeviceId
|
admin.jellyfinDeviceId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ 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;
|
||||||
@@ -342,6 +343,7 @@ class Settings {
|
|||||||
jellyfinForgotPasswordUrl: '',
|
jellyfinForgotPasswordUrl: '',
|
||||||
libraries: [],
|
libraries: [],
|
||||||
serverId: '',
|
serverId: '',
|
||||||
|
apiKey: '',
|
||||||
},
|
},
|
||||||
tautulli: {},
|
tautulli: {},
|
||||||
radarr: [],
|
radarr: [],
|
||||||
@@ -629,7 +631,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 load(overrideSettings?: AllSettings): Settings {
|
public async load(overrideSettings?: AllSettings): Promise<Settings> {
|
||||||
if (overrideSettings) {
|
if (overrideSettings) {
|
||||||
this.data = overrideSettings;
|
this.data = overrideSettings;
|
||||||
return this;
|
return this;
|
||||||
@@ -642,7 +644,7 @@ class Settings {
|
|||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
const parsedJson = JSON.parse(data);
|
const parsedJson = JSON.parse(data);
|
||||||
this.data = runMigrations(parsedJson);
|
this.data = await runMigrations(parsedJson);
|
||||||
|
|
||||||
this.data = merge(this.data, parsedJson);
|
this.data = merge(this.data, parsedJson);
|
||||||
|
|
||||||
|
|||||||
36
server/lib/settings/migrations/0002_migrate_apitokens.ts
Normal file
36
server/lib/settings/migrations/0002_migrate_apitokens.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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;
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
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 = (settings: AllSettings): AllSettings => {
|
export const runMigrations = async (
|
||||||
|
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'))
|
||||||
@@ -13,8 +16,15 @@ export const runMigrations = (settings: AllSettings): AllSettings => {
|
|||||||
|
|
||||||
let migrated = settings;
|
let migrated = settings;
|
||||||
|
|
||||||
for (const migration of migrations) {
|
try {
|
||||||
migrated = migration(migrated);
|
for (const migration of migrations) {
|
||||||
|
migrated = await migration(migrated);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
`Something went wrong while running settings migrations: ${e.message}`,
|
||||||
|
{ label: 'Settings Migrator' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return migrated;
|
return migrated;
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export interface MovieDetails {
|
|||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
watchProviders?: WatchProviders[];
|
watchProviders?: WatchProviders[];
|
||||||
keywords: Keyword[];
|
keywords: Keyword[];
|
||||||
|
onUserWatchlist?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mapProductionCompany = (
|
export const mapProductionCompany = (
|
||||||
@@ -101,7 +102,8 @@ 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,
|
||||||
@@ -148,4 +150,5 @@ export const mapMovieDetails = (
|
|||||||
id: keyword.id,
|
id: keyword.id,
|
||||||
name: keyword.name,
|
name: keyword.name,
|
||||||
})),
|
})),
|
||||||
|
onUserWatchlist: userWatchlist,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ 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 => ({
|
||||||
@@ -161,7 +162,8 @@ 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,
|
||||||
@@ -223,4 +225,5 @@ export const mapTvDetails = (
|
|||||||
})),
|
})),
|
||||||
mediaInfo: media,
|
mediaInfo: media,
|
||||||
watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}),
|
watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}),
|
||||||
|
onUserWatchlist: userWatchlist,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -320,18 +320,28 @@ 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,
|
email: body.email || account.User.Name,
|
||||||
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 ?? '', { default: 'mm', size: 200 }),
|
: gravatarUrl(body.email || account.User.Name, {
|
||||||
|
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;
|
||||||
@@ -340,6 +350,7 @@ 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();
|
||||||
|
|
||||||
@@ -363,15 +374,11 @@ 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) {
|
||||||
user.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`;
|
||||||
} else {
|
} else {
|
||||||
user.avatar = gravatarUrl(user.email, {
|
user.avatar = gravatarUrl(user.email || account.User.Name, {
|
||||||
default: 'mm',
|
default: 'mm',
|
||||||
size: 200,
|
size: 200,
|
||||||
});
|
});
|
||||||
@@ -413,20 +420,18 @@ 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, { default: 'mm', size: 200 }),
|
: gravatarUrl(body.email || account.User.Name, {
|
||||||
|
default: 'mm',
|
||||||
|
size: 200,
|
||||||
|
}),
|
||||||
userType: UserType.JELLYFIN,
|
userType: UserType.JELLYFIN,
|
||||||
});
|
});
|
||||||
//initialize Jellyfin/Emby users with local login
|
//initialize Jellyfin/Emby users with local login
|
||||||
@@ -730,6 +735,7 @@ 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',
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ 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';
|
||||||
@@ -22,7 +24,24 @@ 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);
|
||||||
|
|
||||||
return res.status(200).json(mapMovieDetails(tmdbMovie, media));
|
const onUserWatchlist = await getRepository(Watchlist).exist({
|
||||||
|
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',
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
import {
|
import {
|
||||||
MediaRequestStatus,
|
MediaRequestStatus,
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
@@ -19,6 +21,7 @@ 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';
|
||||||
@@ -143,6 +146,62 @@ 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),
|
||||||
@@ -150,7 +209,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
results: requestCount,
|
results: requestCount,
|
||||||
page: Math.ceil(skip / pageSize) + 1,
|
page: Math.ceil(skip / pageSize) + 1,
|
||||||
},
|
},
|
||||||
results: requests,
|
results: requestsWithProfileNames,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next({ status: 500, message: e.message });
|
next({ status: 500, message: e.message });
|
||||||
|
|||||||
@@ -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', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
|
select: ['id', '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),
|
||||||
admin.jellyfinAuthToken ?? '',
|
tempJellyfinSettings.apiKey,
|
||||||
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', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
|
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
getHostname(),
|
getHostname(),
|
||||||
admin.jellyfinAuthToken ?? '',
|
settings.jellyfin.apiKey,
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -376,7 +376,8 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
||||||
const { externalHostname } = getSettings().jellyfin;
|
const settings = getSettings();
|
||||||
|
const { externalHostname } = settings.jellyfin;
|
||||||
const jellyfinHost =
|
const jellyfinHost =
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
@@ -384,12 +385,13 @@ 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', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
|
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
admin.jellyfinAuthToken ?? '',
|
getHostname(),
|
||||||
|
settings.jellyfin.apiKey,
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
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';
|
||||||
@@ -19,7 +21,24 @@ 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);
|
||||||
|
|
||||||
return res.status(200).json(mapTvDetails(tv, media));
|
const onUserWatchlist = await getRepository(Watchlist).exist({
|
||||||
|
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',
|
||||||
|
|||||||
@@ -41,7 +41,19 @@ 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.plexUsername IS NULL OR user.plexUsername = '') THEN user.email ELSE LOWER(user.plexUsername) END) ELSE LOWER(user.username) END)",
|
`CASE WHEN (user.username IS NULL OR user.username = '') THEN (
|
||||||
|
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;
|
||||||
@@ -90,12 +102,13 @@ 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: body.email.toLowerCase(),
|
email: email.toLowerCase(),
|
||||||
})
|
})
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
@@ -108,7 +121,7 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passedExplicitPassword = body.password && body.password.length > 0;
|
const passedExplicitPassword = body.password && body.password.length > 0;
|
||||||
const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 });
|
const avatar = gravatarUrl(email, { default: 'mm', size: 200 });
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!passedExplicitPassword &&
|
!passedExplicitPassword &&
|
||||||
@@ -118,9 +131,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: '',
|
||||||
@@ -488,16 +501,14 @@ 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: [
|
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
|
||||||
'id',
|
|
||||||
'jellyfinAuthToken',
|
|
||||||
'jellyfinDeviceId',
|
|
||||||
'jellyfinUserId',
|
|
||||||
],
|
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hostname = getHostname();
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
admin.jellyfinAuthToken ?? '',
|
hostname,
|
||||||
|
settings.jellyfin.apiKey,
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
||||||
@@ -505,7 +516,6 @@ router.post(
|
|||||||
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
||||||
const createdUsers: User[] = [];
|
const createdUsers: User[] = [];
|
||||||
const { externalHostname } = getSettings().jellyfin;
|
const { externalHostname } = getSettings().jellyfin;
|
||||||
const hostname = getHostname();
|
|
||||||
|
|
||||||
const jellyfinHost =
|
const jellyfinHost =
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
|
|||||||
@@ -98,7 +98,9 @@ userSettingsRoutes.post<
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.username = req.body.username;
|
user.username = req.body.username;
|
||||||
user.email = req.body.email ?? user.email;
|
if (user.jellyfinUsername) {
|
||||||
|
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 (
|
||||||
|
|||||||
68
server/utils/rateLimit.ts
Normal file
68
server/utils/rateLimit.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
export type RateLimitOptions = {
|
||||||
|
maxRPS: number;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RateLimiteState<T extends (...args: Parameters<T>) => Promise<U>, U> = {
|
||||||
|
queue: {
|
||||||
|
args: Parameters<T>;
|
||||||
|
resolve: (value: U) => void;
|
||||||
|
reject: (reason?: unknown) => void;
|
||||||
|
}[];
|
||||||
|
lastTimestamps: number[];
|
||||||
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rateLimitById: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a rate limit to a function so it doesn't exceed a maximum number of requests per second. Function calls exceeding the rate will be delayed.
|
||||||
|
* @param fn The function to rate limit
|
||||||
|
* @param options.maxRPS Maximum number of Requests Per Second
|
||||||
|
* @param options.id An ID to share between rate limits, so it uses the same request queue.
|
||||||
|
* @returns The function with a rate limit
|
||||||
|
*/
|
||||||
|
export default function rateLimit<
|
||||||
|
T extends (...args: Parameters<T>) => Promise<U>,
|
||||||
|
U
|
||||||
|
>(fn: T, options: RateLimitOptions): (...args: Parameters<T>) => Promise<U> {
|
||||||
|
const state: RateLimiteState<T, U> = (rateLimitById[
|
||||||
|
options.id || ''
|
||||||
|
] as RateLimiteState<T, U>) || { queue: [], lastTimestamps: [] };
|
||||||
|
if (options.id) {
|
||||||
|
rateLimitById[options.id] = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processQueue = () => {
|
||||||
|
// remove old timestamps
|
||||||
|
state.lastTimestamps = state.lastTimestamps.filter(
|
||||||
|
(timestamp) => Date.now() - timestamp < 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
if (state.lastTimestamps.length < options.maxRPS) {
|
||||||
|
// process requests if RPS not exceeded
|
||||||
|
const item = state.queue.shift();
|
||||||
|
if (!item) return;
|
||||||
|
state.lastTimestamps.push(Date.now());
|
||||||
|
const { args, resolve, reject } = item;
|
||||||
|
fn(...args)
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject);
|
||||||
|
processQueue();
|
||||||
|
} else {
|
||||||
|
// rerun once the oldest item in queue is older than 1s
|
||||||
|
if (state.timeout) clearTimeout(state.timeout);
|
||||||
|
state.timeout = setTimeout(
|
||||||
|
processQueue,
|
||||||
|
1000 - (Date.now() - state.lastTimestamps[0])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (...args: Parameters<T>): Promise<U> => {
|
||||||
|
return new Promise<U>((resolve, reject) => {
|
||||||
|
state.queue.push({ args, resolve, reject });
|
||||||
|
processQueue();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1 +1,85 @@
|
|||||||
<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>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- 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: 1.1 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -14,7 +14,6 @@ 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';
|
||||||
@@ -77,11 +76,9 @@ 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 keyword = await axios.get<Keyword>(
|
const res = await fetch(`/api/v1/keyword/${keywordId}`);
|
||||||
`/api/v1/keyword/${keywordId}`
|
const keyword: Keyword = await res.json();
|
||||||
);
|
return keyword;
|
||||||
|
|
||||||
return keyword.data;
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -98,15 +95,13 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.get<TmdbGenre[]>(
|
const res = await fetch(
|
||||||
`/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 = response.data.find(
|
const genre = genres.find((genre) => genre.id === Number(slider.data));
|
||||||
(genre) => genre.id === Number(slider.data)
|
|
||||||
);
|
|
||||||
|
|
||||||
setDefaultDataValue([
|
setDefaultDataValue([
|
||||||
{
|
{
|
||||||
@@ -121,11 +116,8 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.get<ProductionCompany>(
|
const res = await fetch(`/api/v1/studio/${slider.data}`);
|
||||||
`/api/v1/studio/${slider.data}`
|
const studio: ProductionCompany = await res.json();
|
||||||
);
|
|
||||||
|
|
||||||
const studio = response.data;
|
|
||||||
|
|
||||||
setDefaultDataValue([
|
setDefaultDataValue([
|
||||||
{
|
{
|
||||||
@@ -168,16 +160,17 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const loadKeywordOptions = async (inputValue: string) => {
|
const loadKeywordOptions = async (inputValue: string) => {
|
||||||
const results = await axios.get<TmdbKeywordSearchResponse>(
|
const res = await fetch(
|
||||||
'/api/v1/search/keyword',
|
`/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`,
|
||||||
{
|
{
|
||||||
params: {
|
headers: {
|
||||||
query: encodeURIExtraParams(inputValue),
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
const results: TmdbKeywordSearchResponse = await res.json();
|
||||||
|
|
||||||
return results.data.results.map((result) => ({
|
return results.results.map((result) => ({
|
||||||
label: result.name,
|
label: result.name,
|
||||||
value: result.id,
|
value: result.id,
|
||||||
}));
|
}));
|
||||||
@@ -188,38 +181,37 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await axios.get<TmdbCompanySearchResponse>(
|
const res = await fetch(
|
||||||
'/api/v1/search/company',
|
`/api/v1/search/company?query=${encodeURIExtraParams(inputValue)}`,
|
||||||
{
|
{
|
||||||
params: {
|
headers: {
|
||||||
query: encodeURIExtraParams(inputValue),
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
const results: TmdbCompanySearchResponse = await res.json();
|
||||||
|
|
||||||
return results.data.results.map((result) => ({
|
return results.results.map((result) => ({
|
||||||
label: result.name,
|
label: result.name,
|
||||||
value: result.id,
|
value: result.id,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMovieGenreOptions = async () => {
|
const loadMovieGenreOptions = async () => {
|
||||||
const results = await axios.get<GenreSliderItem[]>(
|
const res = await fetch('/api/v1/discover/genreslider/movie');
|
||||||
'/api/v1/discover/genreslider/movie'
|
const results: GenreSliderItem[] = await res.json();
|
||||||
);
|
|
||||||
|
|
||||||
return results.data.map((result) => ({
|
return results.map((result) => ({
|
||||||
label: result.name,
|
label: result.name,
|
||||||
value: result.id,
|
value: result.id,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadTvGenreOptions = async () => {
|
const loadTvGenreOptions = async () => {
|
||||||
const results = await axios.get<GenreSliderItem[]>(
|
const res = await fetch('/api/v1/discover/genreslider/tv');
|
||||||
'/api/v1/discover/genreslider/tv'
|
const results: GenreSliderItem[] = await res.json();
|
||||||
);
|
|
||||||
|
|
||||||
return results.data.map((result) => ({
|
return results.map((result) => ({
|
||||||
label: result.name,
|
label: result.name,
|
||||||
value: result.id,
|
value: result.id,
|
||||||
}));
|
}));
|
||||||
@@ -314,17 +306,31 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
|||||||
onSubmit={async (values, { resetForm }) => {
|
onSubmit={async (values, { resetForm }) => {
|
||||||
try {
|
try {
|
||||||
if (slider) {
|
if (slider) {
|
||||||
await axios.put(`/api/v1/settings/discover/${slider.id}`, {
|
const res = await fetch(`/api/v1/settings/discover/${slider.id}`, {
|
||||||
type: Number(values.sliderType),
|
method: 'PUT',
|
||||||
title: values.title,
|
headers: {
|
||||||
data: values.data,
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: Number(values.sliderType),
|
||||||
|
title: values.title,
|
||||||
|
data: values.data,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
} else {
|
} else {
|
||||||
await axios.post('/api/v1/settings/discover/add', {
|
const res = await fetch('/api/v1/settings/discover/add', {
|
||||||
type: Number(values.sliderType),
|
method: 'POST',
|
||||||
title: values.title,
|
headers: {
|
||||||
data: values.data,
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: Number(values.sliderType),
|
||||||
|
title: values.title,
|
||||||
|
data: values.data,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
}
|
}
|
||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
|
|||||||
@@ -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="mb-6 flex justify-center">
|
<div className="relative mb-6 flex h-24 justify-center sm:h-32">
|
||||||
<Image
|
<Image
|
||||||
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
|
src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
|
||||||
alt={firstResultData.network.name}
|
alt={firstResultData.network.name}
|
||||||
className="max-h-24 sm:max-h-32"
|
className="object-contain"
|
||||||
fill
|
fill
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ 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';
|
||||||
@@ -78,7 +77,10 @@ const DiscoverSliderEdit = ({
|
|||||||
|
|
||||||
const deleteSlider = async () => {
|
const deleteSlider = async () => {
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/v1/settings/discover/${slider.id}`);
|
const res = await fetch(`/api/v1/settings/discover/${slider.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
addToast(intl.formatMessage(messages.deletesuccess), {
|
addToast(intl.formatMessage(messages.deletesuccess), {
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
|
|||||||
@@ -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="mb-6 flex justify-center">
|
<div className="relative mb-6 flex h-24 justify-center sm:h-32">
|
||||||
<Image
|
<Image
|
||||||
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
|
src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
|
||||||
alt={firstResultData.studio.name}
|
alt={firstResultData.studio.name}
|
||||||
className="max-h-24 sm:max-h-32"
|
className="object-contain"
|
||||||
fill
|
fill
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ 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';
|
||||||
@@ -76,7 +75,14 @@ const Discover = () => {
|
|||||||
|
|
||||||
const updateSliders = async () => {
|
const updateSliders = async () => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/discover', sliders);
|
const res = await fetch('/api/v1/settings/discover', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(sliders),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
addToast(intl.formatMessage(messages.updatesuccess), {
|
addToast(intl.formatMessage(messages.updatesuccess), {
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
@@ -94,7 +100,10 @@ const Discover = () => {
|
|||||||
|
|
||||||
const resetSliders = async () => {
|
const resetSliders = async () => {
|
||||||
try {
|
try {
|
||||||
await axios.get('/api/v1/settings/discover/reset');
|
const res = await fetch('/api/v1/settings/discover/reset', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
addToast(intl.formatMessage(messages.resetsuccess), {
|
addToast(intl.formatMessage(messages.resetsuccess), {
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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 Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -49,7 +48,10 @@ const IssueComment = ({
|
|||||||
|
|
||||||
const deleteComment = async () => {
|
const deleteComment = async () => {
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/v1/issueComment/${comment.id}`);
|
const res = await fetch(`/api/v1/issueComment/${comment.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// something went wrong deleting the comment
|
// something went wrong deleting the comment
|
||||||
} finally {
|
} finally {
|
||||||
@@ -175,9 +177,17 @@ const IssueComment = ({
|
|||||||
<Formik
|
<Formik
|
||||||
initialValues={{ newMessage: comment.message }}
|
initialValues={{ newMessage: comment.message }}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
await axios.put(`/api/v1/issueComment/${comment.id}`, {
|
const res = await fetch(
|
||||||
message: values.newMessage,
|
`/api/v1/issueComment/${comment.id}`,
|
||||||
});
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message: values.newMessage }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
if (onUpdate) {
|
if (onUpdate) {
|
||||||
onUpdate();
|
onUpdate();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import useDeepLinks from '@app/hooks/useDeepLinks';
|
|||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import ErrorPage 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,7 +27,6 @@ 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 Image from 'next/image';
|
||||||
@@ -116,7 +115,7 @@ const IssueDetails = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!data || !issueData) {
|
if (!data || !issueData) {
|
||||||
return <Error statusCode={404} />;
|
return <ErrorPage statusCode={404} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const belongsToUser = issueData.createdBy.id === currentUser?.id;
|
const belongsToUser = issueData.createdBy.id === currentUser?.id;
|
||||||
@@ -125,9 +124,14 @@ const IssueDetails = () => {
|
|||||||
|
|
||||||
const editFirstComment = async (newMessage: string) => {
|
const editFirstComment = async (newMessage: string) => {
|
||||||
try {
|
try {
|
||||||
await axios.put(`/api/v1/issueComment/${firstComment.id}`, {
|
const res = await fetch(`/api/v1/issueComment/${firstComment.id}`, {
|
||||||
message: newMessage,
|
method: 'PUT',
|
||||||
|
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',
|
||||||
@@ -144,7 +148,10 @@ const IssueDetails = () => {
|
|||||||
|
|
||||||
const updateIssueStatus = async (newStatus: 'open' | 'resolved') => {
|
const updateIssueStatus = async (newStatus: 'open' | 'resolved') => {
|
||||||
try {
|
try {
|
||||||
await axios.post(`/api/v1/issue/${issueData.id}/${newStatus}`);
|
const res = await fetch(`/api/v1/issue/${issueData.id}/${newStatus}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
addToast(intl.formatMessage(messages.toaststatusupdated), {
|
addToast(intl.formatMessage(messages.toaststatusupdated), {
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
@@ -161,7 +168,10 @@ const IssueDetails = () => {
|
|||||||
|
|
||||||
const deleteIssue = async () => {
|
const deleteIssue = async () => {
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/v1/issue/${issueData.id}`);
|
const res = await fetch(`/api/v1/issue/${issueData.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
addToast(intl.formatMessage(messages.toastissuedeleted), {
|
addToast(intl.formatMessage(messages.toastissuedeleted), {
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
@@ -490,9 +500,17 @@ const IssueDetails = () => {
|
|||||||
}}
|
}}
|
||||||
validationSchema={CommentSchema}
|
validationSchema={CommentSchema}
|
||||||
onSubmit={async (values, { resetForm }) => {
|
onSubmit={async (values, { resetForm }) => {
|
||||||
await axios.post(`/api/v1/issue/${issueData?.id}/comment`, {
|
const res = await fetch(
|
||||||
message: values.message,
|
`/api/v1/issue/${issueData?.id}/comment`,
|
||||||
});
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message: values.message }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
revalidateIssue();
|
revalidateIssue();
|
||||||
resetForm();
|
resetForm();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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';
|
||||||
@@ -101,14 +100,22 @@ const CreateIssueModal = ({
|
|||||||
validationSchema={CreateIssueModalSchema}
|
validationSchema={CreateIssueModalSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
const newIssue = await axios.post<Issue>('/api/v1/issue', {
|
const res = await fetch('/api/v1/issue', {
|
||||||
issueType: values.selectedIssue.issueType,
|
method: 'POST',
|
||||||
message: values.message,
|
headers: {
|
||||||
mediaId: data?.mediaInfo?.id,
|
'Content-Type': 'application/json',
|
||||||
problemSeason: values.problemSeason,
|
},
|
||||||
problemEpisode:
|
body: JSON.stringify({
|
||||||
values.problemSeason > 0 ? values.problemEpisode : 0,
|
issueType: values.selectedIssue.issueType,
|
||||||
|
message: values.message,
|
||||||
|
mediaId: data?.mediaInfo?.id,
|
||||||
|
problemSeason: values.problemSeason,
|
||||||
|
problemEpisode:
|
||||||
|
values.problemSeason > 0 ? values.problemEpisode : 0,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const newIssue: Issue = await res.json();
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
addToast(
|
addToast(
|
||||||
@@ -119,7 +126,7 @@ const CreateIssueModal = ({
|
|||||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/issues/${newIssue.data.id}`} legacyBehavior>
|
<Link href={`/issues/${newIssue.id}`} legacyBehavior>
|
||||||
<Button as="a" className="mt-4">
|
<Button as="a" className="mt-4">
|
||||||
<span>{intl.formatMessage(messages.toastviewissue)}</span>
|
<span>{intl.formatMessage(messages.toastviewissue)}</span>
|
||||||
<ArrowRightCircleIcon />
|
<ArrowRightCircleIcon />
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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 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';
|
||||||
@@ -39,9 +38,13 @@ const UserDropdown = () => {
|
|||||||
const { user, revalidate } = useUser();
|
const { user, revalidate } = useUser();
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
const response = await axios.post('/api/v1/auth/logout');
|
const res = await fetch('/api/v1/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
if (response.data?.status === 'ok') {
|
if (data?.status === 'ok') {
|
||||||
revalidate();
|
revalidate();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -87,9 +90,11 @@ 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>
|
||||||
<span className="truncate text-sm text-gray-400">
|
{user?.displayName?.toLowerCase() !== user?.email && (
|
||||||
{user?.email}
|
<span className="truncate text-sm text-gray-400">
|
||||||
</span>
|
{user?.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{user && <MiniQuotaDisplay userId={user?.id} />}
|
{user && <MiniQuotaDisplay userId={user?.id} />}
|
||||||
|
|||||||
@@ -17,47 +17,45 @@ interface UserWarningsProps {
|
|||||||
const UserWarnings: React.FC<UserWarningsProps> = ({ onClick }) => {
|
const UserWarnings: React.FC<UserWarningsProps> = ({ onClick }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
if (!user) {
|
//check if a user has warnings
|
||||||
|
if (!user || !user.warnings || user.warnings.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let res = null;
|
let res = null;
|
||||||
|
|
||||||
//check if a user has warnings
|
user.warnings.forEach((warning) => {
|
||||||
if (user.warnings.length > 0) {
|
let link = '';
|
||||||
user.warnings.forEach((warning) => {
|
let warningText = '';
|
||||||
let link = '';
|
let warningTitle = '';
|
||||||
let warningText = '';
|
switch (warning) {
|
||||||
let warningTitle = '';
|
case 'userEmailRequired':
|
||||||
switch (warning) {
|
link = '/profile/settings/';
|
||||||
case 'userEmailRequired':
|
warningTitle = 'Profile is incomplete';
|
||||||
link = '/profile/settings/';
|
warningText = intl.formatMessage(messages.emailRequired);
|
||||||
warningTitle = 'Profile is incomplete';
|
}
|
||||||
warningText = intl.formatMessage(messages.emailRequired);
|
|
||||||
}
|
|
||||||
|
|
||||||
res = (
|
res = (
|
||||||
<Link
|
<Link
|
||||||
href={link}
|
href={link}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && onClick) {
|
if (e.key === 'Enter' && onClick) {
|
||||||
onClick();
|
onClick();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="mx-2 mb-2 flex items-center rounded-lg bg-yellow-500 p-2 text-xs text-white ring-1 ring-gray-700 transition duration-300 hover:bg-yellow-400"
|
className="mx-2 mb-2 flex items-center rounded-lg bg-yellow-500 p-2 text-xs text-white ring-1 ring-gray-700 transition duration-300 hover:bg-yellow-400"
|
||||||
>
|
>
|
||||||
<ExclamationTriangleIcon className="h-6 w-6" />
|
<ExclamationTriangleIcon className="h-6 w-6" />
|
||||||
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
|
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
|
||||||
<span className="font-bold">{warningTitle}</span>
|
<span className="font-bold">{warningTitle}</span>
|
||||||
<span className="truncate">{warningText}</span>
|
<span className="truncate">{warningText}</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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';
|
||||||
@@ -58,11 +57,18 @@ const AddEmailModal: React.FC<AddEmailModalProps> = ({
|
|||||||
validationSchema={EmailSettingsSchema}
|
validationSchema={EmailSettingsSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/auth/jellyfin', {
|
const res = await fetch('/api/v1/auth/jellyfin', {
|
||||||
username: username,
|
method: 'POST',
|
||||||
password: password,
|
headers: {
|
||||||
email: values.email,
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
email: values.email,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
onSave();
|
onSave();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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';
|
||||||
@@ -105,18 +104,32 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
validationSchema={LoginSchema}
|
validationSchema={LoginSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/auth/jellyfin', {
|
const res = await fetch('/api/v1/auth/jellyfin', {
|
||||||
username: values.username,
|
method: 'POST',
|
||||||
password: values.password,
|
headers: {
|
||||||
hostname: values.hostname,
|
'Content-Type': 'application/json',
|
||||||
port: values.port,
|
},
|
||||||
useSsl: values.useSsl,
|
body: JSON.stringify({
|
||||||
urlBase: values.urlBase,
|
username: values.username,
|
||||||
email: values.email,
|
password: values.password,
|
||||||
|
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 (e.response?.data?.message) {
|
switch (errorData?.message) {
|
||||||
case ApiErrorCode.InvalidUrl:
|
case ApiErrorCode.InvalidUrl:
|
||||||
errorMessage = messages.invalidurlerror;
|
errorMessage = messages.invalidurlerror;
|
||||||
break;
|
break;
|
||||||
@@ -339,11 +352,18 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
validationSchema={LoginSchema}
|
validationSchema={LoginSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/auth/jellyfin', {
|
const res = await fetch('/api/v1/auth/jellyfin', {
|
||||||
username: values.username,
|
method: 'POST',
|
||||||
password: values.password,
|
headers: {
|
||||||
email: values.username,
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: values.username,
|
||||||
|
password: values.password,
|
||||||
|
email: values.username,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toasts.addToast(
|
toasts.addToast(
|
||||||
intl.formatMessage(
|
intl.formatMessage(
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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';
|
||||||
@@ -56,10 +55,17 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
|||||||
validationSchema={LoginSchema}
|
validationSchema={LoginSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/auth/local', {
|
const res = await fetch('/api/v1/auth/local', {
|
||||||
email: values.email,
|
method: 'POST',
|
||||||
password: values.password,
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: values.email,
|
||||||
|
password: values.password,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoginError(intl.formatMessage(messages.loginerror));
|
setLoginError(intl.formatMessage(messages.loginerror));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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';
|
||||||
@@ -44,13 +43,28 @@ const Login = () => {
|
|||||||
const login = async () => {
|
const login = async () => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/api/v1/auth/plex', { authToken });
|
const res = await fetch('/api/v1/auth/plex', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ authToken }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
if (response.data?.id) {
|
if (data?.id) {
|
||||||
revalidate();
|
revalidate();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.response.data.message);
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
setError(errorData?.message);
|
||||||
setAuthToken(undefined);
|
setAuthToken(undefined);
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ 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 Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -113,16 +112,29 @@ const ManageSlideOver = ({
|
|||||||
|
|
||||||
const deleteMedia = async () => {
|
const deleteMedia = async () => {
|
||||||
if (data.mediaInfo) {
|
if (data.mediaInfo) {
|
||||||
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
|
const res = await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
revalidate();
|
revalidate();
|
||||||
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteMediaFile = async () => {
|
const deleteMediaFile = async () => {
|
||||||
if (data.mediaInfo) {
|
if (data.mediaInfo) {
|
||||||
await axios.delete(`/api/v1/media/${data.mediaInfo.id}/file`);
|
const res1 = await fetch(`/api/v1/media/${data.mediaInfo.id}/file`, {
|
||||||
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res1.ok) throw new Error();
|
||||||
|
|
||||||
|
const res2 = await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res2.ok) throw new Error();
|
||||||
|
|
||||||
revalidate();
|
revalidate();
|
||||||
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,9 +161,16 @@ const ManageSlideOver = ({
|
|||||||
|
|
||||||
const markAvailable = async (is4k = false) => {
|
const markAvailable = async (is4k = false) => {
|
||||||
if (data.mediaInfo) {
|
if (data.mediaInfo) {
|
||||||
await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, {
|
const res = await fetch(`/api/v1/media/${data.mediaInfo?.id}/available`, {
|
||||||
is4k,
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
is4k,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
revalidate();
|
revalidate();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,44 +57,48 @@ 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 flex h-full flex-wrap items-center justify-center opacity-30">
|
<div className="relative z-10 grid h-full w-full grid-cols-2 items-center justify-center gap-2 opacity-30">
|
||||||
{posters[0] && (
|
{posters[0] && (
|
||||||
<div className="w-1/2 p-1">
|
<div className="">
|
||||||
<Image
|
<Image
|
||||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`}
|
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full rounded-md"
|
className="rounded-md"
|
||||||
fill
|
width={300}
|
||||||
|
height={450}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{posters[1] && (
|
{posters[1] && (
|
||||||
<div className="w-1/2 p-1">
|
<div className="">
|
||||||
<Image
|
<Image
|
||||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`}
|
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full rounded-md"
|
className="rounded-md"
|
||||||
fill
|
width={300}
|
||||||
|
height={450}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{posters[2] && (
|
{posters[2] && (
|
||||||
<div className="w-1/2 p-1">
|
<div className="">
|
||||||
<Image
|
<Image
|
||||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`}
|
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full rounded-md"
|
className="rounded-md"
|
||||||
fill
|
width={300}
|
||||||
|
height={450}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{posters[3] && (
|
{posters[3] && (
|
||||||
<div className="w-1/2 p-1">
|
<div className="">
|
||||||
<Image
|
<Image
|
||||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`}
|
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full rounded-md"
|
className="rounded-md"
|
||||||
fill
|
width={300}
|
||||||
|
height={450}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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';
|
||||||
@@ -25,7 +26,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 Error from '@app/pages/_error';
|
import ErrorPage 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';
|
||||||
@@ -41,10 +42,12 @@ 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 } from '@server/constants/media';
|
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
|
import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
|
||||||
import { countries } from 'country-flag-icons';
|
import { countries } from 'country-flag-icons';
|
||||||
@@ -55,6 +58,7 @@ 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', {
|
||||||
@@ -94,6 +98,12 @@ 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 {
|
||||||
@@ -112,7 +122,12 @@ 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,
|
||||||
@@ -154,7 +169,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <Error statusCode={404} />;
|
return <ErrorPage statusCode={404} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showAllStudios = data.productionCompanies.length <= minStudios + 1;
|
const showAllStudios = data.productionCompanies.length <= minStudios + 1;
|
||||||
@@ -287,6 +302,80 @@ 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"
|
||||||
@@ -408,6 +497,40 @@ 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"
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ 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';
|
||||||
@@ -53,7 +52,10 @@ 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);
|
||||||
await axios.post(`/api/v1/request/${request.id}/${type}`);
|
const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
if (onUpdate) {
|
if (onUpdate) {
|
||||||
onUpdate();
|
onUpdate();
|
||||||
@@ -63,7 +65,10 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
|
|||||||
|
|
||||||
const deleteRequest = async () => {
|
const deleteRequest = async () => {
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
await axios.delete(`/api/v1/request/${request.id}`);
|
const res = await fetch(`/api/v1/request/${request.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
if (onUpdate) {
|
if (onUpdate) {
|
||||||
onUpdate();
|
onUpdate();
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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';
|
||||||
|
|
||||||
@@ -94,9 +93,13 @@ const RequestButton = ({
|
|||||||
request: MediaRequest,
|
request: MediaRequest,
|
||||||
type: 'approve' | 'decline'
|
type: 'approve' | 'decline'
|
||||||
) => {
|
) => {
|
||||||
const response = await axios.post(`/api/v1/request/${request.id}/${type}`);
|
const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
if (response) {
|
if (data) {
|
||||||
onUpdate();
|
onUpdate();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -111,7 +114,11 @@ const RequestButton = ({
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
requests.map(async (request) => {
|
requests.map(async (request) => {
|
||||||
return axios.post(`/api/v1/request/${request.id}/${type}`);
|
const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
return res.json();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ 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 Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
@@ -59,7 +59,7 @@ const RequestCardPlaceholder = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface RequestCardErrorProps {
|
interface RequestCardErrorProps {
|
||||||
requestData?: MediaRequest;
|
requestData?: NonFunctionProperties<MediaRequest>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
||||||
@@ -74,7 +74,10 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deleteRequest = async () => {
|
const deleteRequest = async () => {
|
||||||
await axios.delete(`/api/v1/media/${requestData?.media.id}`);
|
const res = await fetch(`/api/v1/media/${requestData?.media.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded');
|
mutate('/api/v1/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');
|
||||||
};
|
};
|
||||||
@@ -211,7 +214,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface RequestCardProps {
|
interface RequestCardProps {
|
||||||
request: MediaRequest;
|
request: NonFunctionProperties<MediaRequest>;
|
||||||
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
|
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,16 +239,19 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
data: requestData,
|
data: requestData,
|
||||||
error: requestError,
|
error: requestError,
|
||||||
mutate: revalidate,
|
mutate: revalidate,
|
||||||
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
|
} = useSWR<NonFunctionProperties<MediaRequest>>(
|
||||||
fallbackData: request,
|
`/api/v1/request/${request.id}`,
|
||||||
refreshInterval: refreshIntervalHelper(
|
{
|
||||||
{
|
fallbackData: request,
|
||||||
downloadStatus: request.media.downloadStatus,
|
refreshInterval: refreshIntervalHelper(
|
||||||
downloadStatus4k: request.media.downloadStatus4k,
|
{
|
||||||
},
|
downloadStatus: request.media.downloadStatus,
|
||||||
15000
|
downloadStatus4k: request.media.downloadStatus4k,
|
||||||
),
|
},
|
||||||
});
|
15000
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
||||||
mediaUrl: requestData?.media?.mediaUrl,
|
mediaUrl: requestData?.media?.mediaUrl,
|
||||||
@@ -255,15 +261,22 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const modifyRequest = async (type: 'approve' | 'decline') => {
|
const modifyRequest = async (type: 'approve' | 'decline') => {
|
||||||
const response = await axios.post(`/api/v1/request/${request.id}/${type}`);
|
const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
if (response) {
|
if (data) {
|
||||||
revalidate();
|
revalidate();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteRequest = async () => {
|
const deleteRequest = async () => {
|
||||||
await axios.delete(`/api/v1/request/${request.id}`);
|
const res = await fetch(`/api/v1/request/${request.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -271,9 +284,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
setRetrying(true);
|
setRetrying(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`/api/v1/request/${request.id}/retry`);
|
const res = await fetch(`/api/v1/request/${request.id}/retry`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
if (response) {
|
if (data) {
|
||||||
revalidate();
|
revalidate();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ 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 Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -43,6 +43,7 @@ const messages = defineMessages('components.RequestList.RequestItem', {
|
|||||||
tmdbid: 'TMDB ID',
|
tmdbid: 'TMDB ID',
|
||||||
tvdbid: 'TheTVDB ID',
|
tvdbid: 'TheTVDB ID',
|
||||||
unknowntitle: 'Unknown Title',
|
unknowntitle: 'Unknown Title',
|
||||||
|
profileName: 'Profile',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||||
@@ -50,7 +51,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface RequestItemErrorProps {
|
interface RequestItemErrorProps {
|
||||||
requestData?: MediaRequest;
|
requestData?: NonFunctionProperties<MediaRequest>;
|
||||||
revalidateList: () => void;
|
revalidateList: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +63,10 @@ const RequestItemError = ({
|
|||||||
const { hasPermission } = useUser();
|
const { hasPermission } = useUser();
|
||||||
|
|
||||||
const deleteRequest = async () => {
|
const deleteRequest = async () => {
|
||||||
await axios.delete(`/api/v1/media/${requestData?.media.id}`);
|
const res = await fetch(`/api/v1/media/${requestData?.media.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
revalidateList();
|
revalidateList();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -283,7 +287,7 @@ const RequestItemError = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface RequestItemProps {
|
interface RequestItemProps {
|
||||||
request: MediaRequest;
|
request: NonFunctionProperties<MediaRequest> & { profileName?: string };
|
||||||
revalidateList: () => void;
|
revalidateList: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,32 +306,38 @@ 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<MediaRequest>(
|
const { data: requestData, mutate: revalidate } = useSWR<
|
||||||
`/api/v1/request/${request.id}`,
|
NonFunctionProperties<MediaRequest>
|
||||||
{
|
>(`/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 response = await axios.post(`/api/v1/request/${request.id}/${type}`);
|
const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
if (response) {
|
if (data) {
|
||||||
revalidate();
|
revalidate();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteRequest = async () => {
|
const deleteRequest = async () => {
|
||||||
await axios.delete(`/api/v1/request/${request.id}`);
|
const res = await fetch(`/api/v1/request/${request.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
revalidateList();
|
revalidateList();
|
||||||
};
|
};
|
||||||
@@ -336,7 +346,12 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
setRetrying(true);
|
setRetrying(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await axios.post(`/api/v1/request/${request.id}/retry`);
|
const res = await fetch(`/api/v1/request/${request.id}/retry`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
revalidate(result.data);
|
revalidate(result.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addToast(intl.formatMessage(messages.failedretry), {
|
addToast(intl.formatMessage(messages.failedretry), {
|
||||||
@@ -387,7 +402,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-4 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-2 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
|
||||||
@@ -468,7 +483,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 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 gap-1 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)}
|
||||||
@@ -618,6 +633,16 @@ 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">
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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';
|
||||||
@@ -197,12 +196,19 @@ const CollectionRequestModal = ({
|
|||||||
(
|
(
|
||||||
data?.parts.filter((part) => selectedParts.includes(part.id)) ?? []
|
data?.parts.filter((part) => selectedParts.includes(part.id)) ?? []
|
||||||
).map(async (part) => {
|
).map(async (part) => {
|
||||||
await axios.post<MediaRequest>('/api/v1/request', {
|
const res = await fetch('/api/v1/request', {
|
||||||
mediaId: part.id,
|
method: 'POST',
|
||||||
mediaType: 'movie',
|
headers: {
|
||||||
is4k,
|
'Content-Type': 'application/json',
|
||||||
...overrideParams,
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
mediaId: part.id,
|
||||||
|
mediaType: 'movie',
|
||||||
|
is4k,
|
||||||
|
...overrideParams,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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?: MediaRequest;
|
editRequest?: NonFunctionProperties<MediaRequest>;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onComplete?: (newStatus: MediaStatus) => void;
|
onComplete?: (newStatus: MediaStatus) => void;
|
||||||
onUpdating?: (isUpdating: boolean) => void;
|
onUpdating?: (isUpdating: boolean) => void;
|
||||||
@@ -89,15 +89,23 @@ const MovieRequestModal = ({
|
|||||||
tags: requestOverrides.tags,
|
tags: requestOverrides.tags,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const response = await axios.post<MediaRequest>('/api/v1/request', {
|
const res = await fetch('/api/v1/request', {
|
||||||
mediaId: data?.id,
|
method: 'POST',
|
||||||
mediaType: 'movie',
|
headers: {
|
||||||
is4k,
|
'Content-Type': 'application/json',
|
||||||
...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 (response.data) {
|
if (mediaRequest) {
|
||||||
if (onComplete) {
|
if (onComplete) {
|
||||||
onComplete(
|
onComplete(
|
||||||
hasPermission(
|
hasPermission(
|
||||||
@@ -136,12 +144,14 @@ const MovieRequestModal = ({
|
|||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.delete<MediaRequest>(
|
const res = await fetch(`/api/v1/request/${editRequest?.id}`, {
|
||||||
`/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');
|
||||||
|
|
||||||
if (response.status === 204) {
|
if (res.status === 204) {
|
||||||
if (onComplete) {
|
if (onComplete) {
|
||||||
onComplete(MediaStatus.UNKNOWN);
|
onComplete(MediaStatus.UNKNOWN);
|
||||||
}
|
}
|
||||||
@@ -164,17 +174,27 @@ const MovieRequestModal = ({
|
|||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.put(`/api/v1/request/${editRequest?.id}`, {
|
const res = await fetch(`/api/v1/request/${editRequest?.id}`, {
|
||||||
mediaType: 'movie',
|
method: 'PUT',
|
||||||
serverId: requestOverrides?.server,
|
headers: {
|
||||||
profileId: requestOverrides?.profile,
|
'Content-Type': 'application/json',
|
||||||
rootFolder: requestOverrides?.folder,
|
},
|
||||||
userId: requestOverrides?.user?.id,
|
body: JSON.stringify({
|
||||||
tags: requestOverrides?.tags,
|
mediaType: 'movie',
|
||||||
|
serverId: requestOverrides?.server,
|
||||||
|
profileId: requestOverrides?.profile,
|
||||||
|
rootFolder: requestOverrides?.folder,
|
||||||
|
userId: requestOverrides?.user?.id,
|
||||||
|
tags: requestOverrides?.tags,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
if (alsoApproveRequest) {
|
if (alsoApproveRequest) {
|
||||||
await axios.post(`/api/v1/request/${editRequest?.id}/approve`);
|
const res = await fetch(`/api/v1/request/${editRequest?.id}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
}
|
}
|
||||||
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
||||||
|
|
||||||
|
|||||||
@@ -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?: MediaRequest;
|
editRequest?: NonFunctionProperties<MediaRequest>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TvRequestModal = ({
|
const TvRequestModal = ({
|
||||||
@@ -111,22 +111,35 @@ const TvRequestModal = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (selectedSeasons.length > 0) {
|
if (selectedSeasons.length > 0) {
|
||||||
await axios.put(`/api/v1/request/${editRequest.id}`, {
|
const res = await fetch(`/api/v1/request/${editRequest.id}`, {
|
||||||
mediaType: 'tv',
|
method: 'PUT',
|
||||||
serverId: requestOverrides?.server,
|
headers: {
|
||||||
profileId: requestOverrides?.profile,
|
'Content-Type': 'application/json',
|
||||||
rootFolder: requestOverrides?.folder,
|
},
|
||||||
languageProfileId: requestOverrides?.language,
|
body: JSON.stringify({
|
||||||
userId: requestOverrides?.user?.id,
|
mediaType: 'tv',
|
||||||
tags: requestOverrides?.tags,
|
serverId: requestOverrides?.server,
|
||||||
seasons: selectedSeasons,
|
profileId: requestOverrides?.profile,
|
||||||
|
rootFolder: requestOverrides?.folder,
|
||||||
|
languageProfileId: requestOverrides?.language,
|
||||||
|
userId: requestOverrides?.user?.id,
|
||||||
|
tags: requestOverrides?.tags,
|
||||||
|
seasons: selectedSeasons,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
if (alsoApproveRequest) {
|
if (alsoApproveRequest) {
|
||||||
await axios.post(`/api/v1/request/${editRequest.id}/approve`);
|
const res = await fetch(`/api/v1/request/${editRequest.id}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await axios.delete(`/api/v1/request/${editRequest.id}`);
|
const res = await fetch(`/api/v1/request/${editRequest.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
}
|
}
|
||||||
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
||||||
|
|
||||||
@@ -191,23 +204,32 @@ const TvRequestModal = ({
|
|||||||
tags: requestOverrides.tags,
|
tags: requestOverrides.tags,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const response = await axios.post<MediaRequest>('/api/v1/request', {
|
const res = await fetch('/api/v1/request', {
|
||||||
mediaId: data?.id,
|
method: 'POST',
|
||||||
tvdbId: tvdbId ?? data?.externalIds.tvdbId,
|
headers: {
|
||||||
mediaType: 'tv',
|
'Content-Type': 'application/json',
|
||||||
is4k,
|
},
|
||||||
seasons: settings.currentSettings.partialRequestsEnabled
|
body: JSON.stringify({
|
||||||
? selectedSeasons
|
mediaId: data?.id,
|
||||||
: getAllSeasons().filter(
|
tvdbId: tvdbId ?? data?.externalIds.tvdbId,
|
||||||
(season) => !getAllRequestedSeasons().includes(season)
|
mediaType: 'tv',
|
||||||
),
|
is4k,
|
||||||
...overrideParams,
|
seasons: settings.currentSettings.partialRequestsEnabled
|
||||||
|
? selectedSeasons
|
||||||
|
: getAllSeasons().filter(
|
||||||
|
(season) => !getAllRequestedSeasons().includes(season)
|
||||||
|
),
|
||||||
|
...overrideParams,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const mediaRequest: MediaRequest = await res.json();
|
||||||
|
|
||||||
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
||||||
|
|
||||||
if (response.data) {
|
if (mediaRequest) {
|
||||||
if (onComplete) {
|
if (onComplete) {
|
||||||
onComplete(response.data.media.status);
|
onComplete(mediaRequest.media.status);
|
||||||
}
|
}
|
||||||
addToast(
|
addToast(
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ 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?: MediaRequest;
|
editRequest?: NonFunctionProperties<MediaRequest>;
|
||||||
onComplete?: (newStatus: MediaStatus) => void;
|
onComplete?: (newStatus: MediaStatus) => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onUpdating?: (isUpdating: boolean) => void;
|
onUpdating?: (isUpdating: boolean) => void;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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';
|
||||||
@@ -85,14 +84,18 @@ const ResetPassword = () => {
|
|||||||
}}
|
}}
|
||||||
validationSchema={ResetSchema}
|
validationSchema={ResetSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
const response = await axios.post(
|
const res = await fetch(`/api/v1/auth/reset-password`, {
|
||||||
`/api/v1/auth/reset-password`,
|
method: 'POST',
|
||||||
{
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
email: values.email,
|
email: values.email,
|
||||||
}
|
}),
|
||||||
);
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (res.status === 200) {
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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';
|
||||||
@@ -100,14 +99,21 @@ const ResetPassword = () => {
|
|||||||
}}
|
}}
|
||||||
validationSchema={ResetSchema}
|
validationSchema={ResetSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
const response = await axios.post(
|
const res = await fetch(
|
||||||
`/api/v1/auth/reset-password/${guid}`,
|
`/api/v1/auth/reset-password/${guid}`,
|
||||||
{
|
{
|
||||||
password: values.password,
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
password: values.password,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (res.status === 200) {
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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';
|
||||||
@@ -69,11 +68,9 @@ export const CompanySelector = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.get<ProductionCompany>(
|
const res = await fetch(`/api/v1/studio/${defaultValue}`);
|
||||||
`/api/v1/studio/${defaultValue}`
|
if (!res.ok) throw new Error();
|
||||||
);
|
const studio: ProductionCompany = await res.json();
|
||||||
|
|
||||||
const studio = response.data;
|
|
||||||
|
|
||||||
setDefaultDataValue([
|
setDefaultDataValue([
|
||||||
{
|
{
|
||||||
@@ -91,16 +88,15 @@ export const CompanySelector = ({
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await axios.get<TmdbCompanySearchResponse>(
|
const res = await fetch(
|
||||||
'/api/v1/search/company',
|
`/api/v1/search/company?query=${encodeURIExtraParams(inputValue)}`
|
||||||
{
|
|
||||||
params: {
|
|
||||||
query: encodeURIExtraParams(inputValue),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Network response was not ok');
|
||||||
|
}
|
||||||
|
const results: TmdbCompanySearchResponse = await res.json();
|
||||||
|
|
||||||
return results.data.results.map((result) => ({
|
return results.results.map((result) => ({
|
||||||
label: result.name,
|
label: result.name,
|
||||||
value: result.id,
|
value: result.id,
|
||||||
}));
|
}));
|
||||||
@@ -154,11 +150,15 @@ export const GenreSelector = ({
|
|||||||
|
|
||||||
const genres = defaultValue.split(',');
|
const genres = defaultValue.split(',');
|
||||||
|
|
||||||
const response = await axios.get<TmdbGenre[]>(`/api/v1/genres/${type}`);
|
const res = await fetch(`/api/v1/genres/${type}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Network response was not ok');
|
||||||
|
}
|
||||||
|
const response: TmdbGenre[] = await res.json();
|
||||||
|
|
||||||
const genreData = genres
|
const genreData = genres
|
||||||
.filter((genre) => response.data.find((gd) => gd.id === Number(genre)))
|
.filter((genre) => response.find((gd) => gd.id === Number(genre)))
|
||||||
.map((g) => response.data.find((gd) => gd.id === Number(g)))
|
.map((g) => response.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 results = await axios.get<GenreSliderItem[]>(
|
const res = await fetch(`/api/v1/discover/genreslider/${type}`);
|
||||||
`/api/v1/discover/genreslider/${type}`
|
if (!res.ok) throw new Error();
|
||||||
);
|
const results: GenreSliderItem[] = await res.json();
|
||||||
|
|
||||||
return results.data
|
return results
|
||||||
.map((result) => ({
|
.map((result) => ({
|
||||||
label: result.name,
|
label: result.name,
|
||||||
value: result.id,
|
value: result.id,
|
||||||
@@ -222,11 +222,13 @@ export const KeywordSelector = ({
|
|||||||
|
|
||||||
const keywords = await Promise.all(
|
const keywords = await Promise.all(
|
||||||
defaultValue.split(',').map(async (keywordId) => {
|
defaultValue.split(',').map(async (keywordId) => {
|
||||||
const keyword = await axios.get<Keyword>(
|
const res = await fetch(`/api/v1/keyword/${keywordId}`);
|
||||||
`/api/v1/keyword/${keywordId}`
|
if (!res.ok) {
|
||||||
);
|
throw new Error('Network response was not ok');
|
||||||
|
}
|
||||||
|
const keyword: Keyword = await res.json();
|
||||||
|
|
||||||
return keyword.data;
|
return keyword;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -242,16 +244,15 @@ export const KeywordSelector = ({
|
|||||||
}, [defaultValue]);
|
}, [defaultValue]);
|
||||||
|
|
||||||
const loadKeywordOptions = async (inputValue: string) => {
|
const loadKeywordOptions = async (inputValue: string) => {
|
||||||
const results = await axios.get<TmdbKeywordSearchResponse>(
|
const res = await fetch(
|
||||||
'/api/v1/search/keyword',
|
`/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`
|
||||||
{
|
|
||||||
params: {
|
|
||||||
query: encodeURIExtraParams(inputValue),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Network response was not ok');
|
||||||
|
}
|
||||||
|
const results: TmdbKeywordSearchResponse = await res.json();
|
||||||
|
|
||||||
return results.data.results.map((result) => ({
|
return results.results.map((result) => ({
|
||||||
label: result.name,
|
label: result.name,
|
||||||
value: result.id,
|
value: result.id,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
/* 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 = () => {
|
||||||
@@ -26,11 +25,18 @@ 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) {
|
||||||
await axios.post('/api/v1/user/registerPushSubscription', {
|
const res = await fetch('/api/v1/user/registerPushSubscription', {
|
||||||
endpoint: parsedSub.endpoint,
|
method: 'POST',
|
||||||
p256dh: parsedSub.keys.p256dh,
|
headers: {
|
||||||
auth: parsedSub.keys.auth,
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
endpoint: parsedSub.endpoint,
|
||||||
|
p256dh: parsedSub.keys.p256dh,
|
||||||
|
auth: parsedSub.keys.auth,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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';
|
||||||
@@ -73,16 +72,23 @@ const NotificationsDiscord = () => {
|
|||||||
validationSchema={NotificationsDiscordSchema}
|
validationSchema={NotificationsDiscordSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/notifications/discord', {
|
const res = await fetch('/api/v1/settings/notifications/discord', {
|
||||||
enabled: values.enabled,
|
method: 'POST',
|
||||||
types: values.types,
|
headers: {
|
||||||
options: {
|
'Content-Type': 'application/json',
|
||||||
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',
|
||||||
@@ -121,16 +127,26 @@ const NotificationsDiscord = () => {
|
|||||||
toastId = id;
|
toastId = id;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
await axios.post('/api/v1/settings/notifications/discord/test', {
|
const res = await fetch(
|
||||||
enabled: true,
|
'/api/v1/settings/notifications/discord/test',
|
||||||
types: values.types,
|
{
|
||||||
options: {
|
method: 'POST',
|
||||||
botUsername: values.botUsername,
|
headers: {
|
||||||
botAvatarUrl: values.botAvatarUrl,
|
'Content-Type': 'application/json',
|
||||||
webhookUrl: values.webhookUrl,
|
},
|
||||||
enableMentions: values.enableMentions,
|
body: JSON.stringify({
|
||||||
},
|
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);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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';
|
||||||
@@ -148,24 +147,31 @@ const NotificationsEmail = () => {
|
|||||||
validationSchema={NotificationsEmailSchema}
|
validationSchema={NotificationsEmailSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/notifications/email', {
|
const res = await fetch('/api/v1/settings/notifications/email', {
|
||||||
enabled: values.enabled,
|
method: 'POST',
|
||||||
options: {
|
headers: {
|
||||||
userEmailRequired: values.userEmailRequired,
|
'Content-Type': 'application/json',
|
||||||
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), {
|
||||||
@@ -197,22 +203,32 @@ const NotificationsEmail = () => {
|
|||||||
toastId = id;
|
toastId = id;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
await axios.post('/api/v1/settings/notifications/email/test', {
|
const res = await fetch(
|
||||||
enabled: true,
|
'/api/v1/settings/notifications/email/test',
|
||||||
options: {
|
{
|
||||||
emailFrom: values.emailFrom,
|
method: 'POST',
|
||||||
smtpHost: values.smtpHost,
|
headers: {
|
||||||
smtpPort: Number(values.smtpPort),
|
'Content-Type': 'application/json',
|
||||||
secure: values.encryption === 'implicit',
|
},
|
||||||
ignoreTls: values.encryption === 'none',
|
body: JSON.stringify({
|
||||||
requireTls: values.encryption === 'opportunistic',
|
enabled: true,
|
||||||
authUser: values.authUser,
|
options: {
|
||||||
authPass: values.authPass,
|
emailFrom: values.emailFrom,
|
||||||
senderName: values.senderName,
|
smtpHost: values.smtpHost,
|
||||||
pgpPrivateKey: values.pgpPrivateKey,
|
smtpPort: Number(values.smtpPort),
|
||||||
pgpPassword: values.pgpPassword,
|
secure: values.encryption === 'implicit',
|
||||||
},
|
ignoreTls: values.encryption === 'none',
|
||||||
});
|
requireTls: values.encryption === 'opportunistic',
|
||||||
|
authUser: values.authUser,
|
||||||
|
authPass: values.authPass,
|
||||||
|
senderName: values.senderName,
|
||||||
|
pgpPrivateKey: values.pgpPrivateKey,
|
||||||
|
pgpPassword: values.pgpPassword,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
if (toastId) {
|
if (toastId) {
|
||||||
removeToast(toastId);
|
removeToast(toastId);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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';
|
||||||
@@ -83,14 +82,21 @@ const NotificationsGotify = () => {
|
|||||||
validationSchema={NotificationsGotifySchema}
|
validationSchema={NotificationsGotifySchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/notifications/gotify', {
|
const res = await fetch('/api/v1/settings/notifications/gotify', {
|
||||||
enabled: values.enabled,
|
method: 'POST',
|
||||||
types: values.types,
|
headers: {
|
||||||
options: {
|
'Content-Type': 'application/json',
|
||||||
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,
|
||||||
@@ -128,14 +134,24 @@ const NotificationsGotify = () => {
|
|||||||
toastId = id;
|
toastId = id;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
await axios.post('/api/v1/settings/notifications/gotify/test', {
|
const res = await fetch(
|
||||||
enabled: true,
|
'/api/v1/settings/notifications/gotify/test',
|
||||||
types: values.types,
|
{
|
||||||
options: {
|
method: 'POST',
|
||||||
url: values.url,
|
headers: {
|
||||||
token: values.token,
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
types: values.types,
|
||||||
|
options: {
|
||||||
|
url: values.url,
|
||||||
|
token: values.token,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
if (toastId) {
|
if (toastId) {
|
||||||
removeToast(toastId);
|
removeToast(toastId);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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';
|
||||||
@@ -69,14 +68,21 @@ const NotificationsLunaSea = () => {
|
|||||||
validationSchema={NotificationsLunaSeaSchema}
|
validationSchema={NotificationsLunaSeaSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/notifications/lunasea', {
|
const res = await fetch('/api/v1/settings/notifications/lunasea', {
|
||||||
enabled: values.enabled,
|
method: 'POST',
|
||||||
types: values.types,
|
headers: {
|
||||||
options: {
|
'Content-Type': 'application/json',
|
||||||
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,
|
||||||
@@ -114,14 +120,24 @@ const NotificationsLunaSea = () => {
|
|||||||
toastId = id;
|
toastId = id;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
await axios.post('/api/v1/settings/notifications/lunasea/test', {
|
const res = await fetch(
|
||||||
enabled: true,
|
'/api/v1/settings/notifications/lunasea/test',
|
||||||
types: values.types,
|
{
|
||||||
options: {
|
method: 'POST',
|
||||||
webhookUrl: values.webhookUrl,
|
headers: {
|
||||||
profileName: values.profileName,
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
types: values.types,
|
||||||
|
options: {
|
||||||
|
webhookUrl: values.webhookUrl,
|
||||||
|
profileName: values.profileName,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
if (toastId) {
|
if (toastId) {
|
||||||
removeToast(toastId);
|
removeToast(toastId);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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,14 +67,21 @@ const NotificationsPushbullet = () => {
|
|||||||
validationSchema={NotificationsPushbulletSchema}
|
validationSchema={NotificationsPushbulletSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/notifications/pushbullet', {
|
const res = await fetch('/api/v1/settings/notifications/pushbullet', {
|
||||||
enabled: values.enabled,
|
method: 'POST',
|
||||||
types: values.types,
|
headers: {
|
||||||
options: {
|
'Content-Type': 'application/json',
|
||||||
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,
|
||||||
@@ -113,14 +119,24 @@ const NotificationsPushbullet = () => {
|
|||||||
toastId = id;
|
toastId = id;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
await axios.post('/api/v1/settings/notifications/pushbullet/test', {
|
const res = await fetch(
|
||||||
enabled: true,
|
'/api/v1/settings/notifications/pushbullet/test',
|
||||||
types: values.types,
|
{
|
||||||
options: {
|
method: 'POST',
|
||||||
accessToken: values.accessToken,
|
headers: {
|
||||||
channelTag: values.channelTag,
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
types: values.types,
|
||||||
|
options: {
|
||||||
|
accessToken: values.accessToken,
|
||||||
|
channelTag: values.channelTag,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
if (toastId) {
|
if (toastId) {
|
||||||
removeToast(toastId);
|
removeToast(toastId);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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';
|
||||||
@@ -94,14 +93,21 @@ const NotificationsPushover = () => {
|
|||||||
validationSchema={NotificationsPushoverSchema}
|
validationSchema={NotificationsPushoverSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/notifications/pushover', {
|
const res = await fetch('/api/v1/settings/notifications/pushover', {
|
||||||
enabled: values.enabled,
|
method: 'POST',
|
||||||
types: values.types,
|
headers: {
|
||||||
options: {
|
'Content-Type': 'application/json',
|
||||||
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,
|
||||||
@@ -139,16 +145,25 @@ const NotificationsPushover = () => {
|
|||||||
toastId = id;
|
toastId = id;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
await axios.post('/api/v1/settings/notifications/pushover/test', {
|
const res = await fetch(
|
||||||
enabled: true,
|
'/api/v1/settings/notifications/pushover/test',
|
||||||
types: values.types,
|
{
|
||||||
options: {
|
method: 'POST',
|
||||||
accessToken: values.accessToken,
|
headers: {
|
||||||
userToken: values.userToken,
|
'Content-Type': 'application/json',
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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';
|
||||||
@@ -65,13 +64,20 @@ const NotificationsSlack = () => {
|
|||||||
validationSchema={NotificationsSlackSchema}
|
validationSchema={NotificationsSlackSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/notifications/slack', {
|
const res = await fetch('/api/v1/settings/notifications/slack', {
|
||||||
enabled: values.enabled,
|
method: 'POST',
|
||||||
types: values.types,
|
headers: {
|
||||||
options: {
|
'Content-Type': 'application/json',
|
||||||
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,
|
||||||
@@ -109,13 +115,23 @@ const NotificationsSlack = () => {
|
|||||||
toastId = id;
|
toastId = id;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
await axios.post('/api/v1/settings/notifications/slack/test', {
|
const res = await fetch(
|
||||||
enabled: true,
|
'/api/v1/settings/notifications/slack/test',
|
||||||
types: values.types,
|
{
|
||||||
options: {
|
method: 'POST',
|
||||||
webhookUrl: values.webhookUrl,
|
headers: {
|
||||||
},
|
'Content-Type': 'application/json',
|
||||||
});
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
types: values.types,
|
||||||
|
options: {
|
||||||
|
webhookUrl: values.webhookUrl,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
if (toastId) {
|
if (toastId) {
|
||||||
removeToast(toastId);
|
removeToast(toastId);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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';
|
||||||
@@ -84,16 +83,23 @@ const NotificationsTelegram = () => {
|
|||||||
validationSchema={NotificationsTelegramSchema}
|
validationSchema={NotificationsTelegramSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/notifications/telegram', {
|
const res = await fetch('/api/v1/settings/notifications/telegram', {
|
||||||
enabled: values.enabled,
|
method: 'POST',
|
||||||
types: values.types,
|
headers: {
|
||||||
options: {
|
'Content-Type': 'application/json',
|
||||||
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',
|
||||||
@@ -132,16 +138,26 @@ const NotificationsTelegram = () => {
|
|||||||
toastId = id;
|
toastId = id;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
await axios.post('/api/v1/settings/notifications/telegram/test', {
|
const res = await fetch(
|
||||||
enabled: true,
|
'/api/v1/settings/notifications/telegram/test',
|
||||||
types: values.types,
|
{
|
||||||
options: {
|
method: 'POST',
|
||||||
botAPI: values.botAPI,
|
headers: {
|
||||||
chatId: values.chatId,
|
'Content-Type': 'application/json',
|
||||||
sendSilently: values.sendSilently,
|
},
|
||||||
botUsername: values.botUsername,
|
body: JSON.stringify({
|
||||||
},
|
enabled: true,
|
||||||
});
|
types: values.types,
|
||||||
|
options: {
|
||||||
|
botAPI: values.botAPI,
|
||||||
|
chatId: values.chatId,
|
||||||
|
sendSilently: values.sendSilently,
|
||||||
|
botUsername: values.botUsername,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
if (toastId) {
|
if (toastId) {
|
||||||
removeToast(toastId);
|
removeToast(toastId);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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';
|
||||||
@@ -58,10 +57,17 @@ const NotificationsWebPush = () => {
|
|||||||
}}
|
}}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/notifications/webpush', {
|
const res = await fetch('/api/v1/settings/notifications/webpush', {
|
||||||
enabled: values.enabled,
|
method: 'POST',
|
||||||
options: {},
|
headers: {
|
||||||
|
'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',
|
||||||
@@ -92,10 +98,20 @@ const NotificationsWebPush = () => {
|
|||||||
toastId = id;
|
toastId = id;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
await axios.post('/api/v1/settings/notifications/webpush/test', {
|
const res = await fetch(
|
||||||
enabled: true,
|
'/api/v1/settings/notifications/webpush/test',
|
||||||
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);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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';
|
||||||
@@ -150,15 +149,22 @@ const NotificationsWebhook = () => {
|
|||||||
validationSchema={NotificationsWebhookSchema}
|
validationSchema={NotificationsWebhookSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/notifications/webhook', {
|
const res = await fetch('/api/v1/settings/notifications/webhook', {
|
||||||
enabled: values.enabled,
|
method: 'POST',
|
||||||
types: values.types,
|
headers: {
|
||||||
options: {
|
'Content-Type': 'application/json',
|
||||||
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,
|
||||||
@@ -207,16 +213,25 @@ const NotificationsWebhook = () => {
|
|||||||
toastId = id;
|
toastId = id;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
await axios.post('/api/v1/settings/notifications/webhook/test', {
|
const res = await fetch(
|
||||||
enabled: true,
|
'/api/v1/settings/notifications/webhook/test',
|
||||||
types: values.types,
|
{
|
||||||
options: {
|
method: 'POST',
|
||||||
webhookUrl: values.webhookUrl,
|
headers: {
|
||||||
jsonPayload: JSON.stringify(values.jsonPayload),
|
'Content-Type': 'application/json',
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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';
|
||||||
@@ -166,19 +165,24 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
|||||||
}) => {
|
}) => {
|
||||||
setIsTesting(true);
|
setIsTesting(true);
|
||||||
try {
|
try {
|
||||||
const response = await axios.post<TestResponse>(
|
const res = await fetch('/api/v1/settings/radarr/test', {
|
||||||
'/api/v1/settings/radarr/test',
|
method: 'POST',
|
||||||
{
|
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(response.data);
|
setTestResponse(data);
|
||||||
if (initialLoad.current) {
|
if (initialLoad.current) {
|
||||||
addToast(intl.formatMessage(messages.toastRadarrTestSuccess), {
|
addToast(intl.formatMessage(messages.toastRadarrTestSuccess), {
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
@@ -271,12 +275,23 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
|||||||
tagRequests: values.tagRequests,
|
tagRequests: values.tagRequests,
|
||||||
};
|
};
|
||||||
if (!radarr) {
|
if (!radarr) {
|
||||||
await axios.post('/api/v1/settings/radarr', submission);
|
const res = await fetch('/api/v1/settings/radarr', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(submission),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
} else {
|
} else {
|
||||||
await axios.put(
|
const res = await fetch(`/api/v1/settings/radarr/${radarr.id}`, {
|
||||||
`/api/v1/settings/radarr/${radarr.id}`,
|
method: 'PUT',
|
||||||
submission
|
headers: {
|
||||||
);
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(submission),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
}
|
}
|
||||||
|
|
||||||
onSave();
|
onSave();
|
||||||
|
|||||||
@@ -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,13 +31,14 @@ 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.',
|
'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.',
|
||||||
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',
|
||||||
@@ -167,13 +168,25 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.get('/api/v1/settings/jellyfin/library', {
|
const searchParams = new URLSearchParams({
|
||||||
params,
|
sync: params.sync ? 'true' : 'false',
|
||||||
|
...(params.enable ? { enable: params.enable } : {}),
|
||||||
});
|
});
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/settings/jellyfin/library?${searchParams.toString()}`
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||||
setIsSyncing(false);
|
setIsSyncing(false);
|
||||||
revalidate();
|
revalidate();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.response.data.message === 'SYNC_ERROR_GROUPED_FOLDERS') {
|
let errorData;
|
||||||
|
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
|
||||||
@@ -183,7 +196,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
appearance: 'warning',
|
appearance: 'warning',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else if (e.response.data.message === 'SYNC_ERROR_NO_LIBRARIES') {
|
} else if (errorData?.message === 'SYNC_ERROR_NO_LIBRARIES') {
|
||||||
toasts.addToast(
|
toasts.addToast(
|
||||||
intl.formatMessage(messages.jellyfinSyncFailedNoLibrariesFound),
|
intl.formatMessage(messages.jellyfinSyncFailedNoLibrariesFound),
|
||||||
{
|
{
|
||||||
@@ -206,16 +219,32 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startScan = async () => {
|
const startScan = async () => {
|
||||||
await axios.post('/api/v1/settings/jellyfin/sync', {
|
const res = await fetch('/api/v1/settings/jellyfin/sync', {
|
||||||
start: true,
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
start: true,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
revalidateSync();
|
revalidateSync();
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelScan = async () => {
|
const cancelScan = async () => {
|
||||||
await axios.post('/api/v1/settings/jellyfin/sync', {
|
const res = await fetch('/api/v1/settings/jellyfin/sync', {
|
||||||
cancel: true,
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
cancel: true,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
revalidateSync();
|
revalidateSync();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -230,15 +259,19 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
.join(',');
|
.join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
await axios.get('/api/v1/settings/jellyfin/library', {
|
const searchParams = new URLSearchParams(params.enable ? params : {});
|
||||||
params,
|
const res = await fetch(
|
||||||
});
|
`/api/v1/settings/jellyfin/library?${searchParams.toString()}`
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
} else {
|
} else {
|
||||||
await axios.get('/api/v1/settings/jellyfin/library', {
|
const searchParams = new URLSearchParams({
|
||||||
params: {
|
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();
|
||||||
@@ -413,105 +446,121 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showAdvancedSettings && (
|
<div className="mt-10 mb-6">
|
||||||
<>
|
<h3 className="heading">
|
||||||
<div className="mt-10 mb-6">
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
<h3 className="heading">
|
? intl.formatMessage(messages.jellyfinSettings, {
|
||||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
mediaServerName: 'Emby',
|
||||||
? intl.formatMessage(messages.jellyfinSettings, {
|
})
|
||||||
mediaServerName: 'Emby',
|
: intl.formatMessage(messages.jellyfinSettings, {
|
||||||
})
|
mediaServerName: 'Jellyfin',
|
||||||
: intl.formatMessage(messages.jellyfinSettings, {
|
})}
|
||||||
mediaServerName: 'Jellyfin',
|
</h3>
|
||||||
})}
|
<p className="description">
|
||||||
</h3>
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
<p className="description">
|
? intl.formatMessage(messages.jellyfinSettingsDescription, {
|
||||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
mediaServerName: 'Emby',
|
||||||
? intl.formatMessage(messages.jellyfinSettingsDescription, {
|
})
|
||||||
mediaServerName: 'Emby',
|
: intl.formatMessage(messages.jellyfinSettingsDescription, {
|
||||||
})
|
mediaServerName: 'Jellyfin',
|
||||||
: intl.formatMessage(messages.jellyfinSettingsDescription, {
|
})}
|
||||||
mediaServerName: 'Jellyfin',
|
</p>
|
||||||
})}
|
</div>
|
||||||
</p>
|
<Formik
|
||||||
</div>
|
initialValues={{
|
||||||
<Formik
|
hostname: data?.ip,
|
||||||
initialValues={{
|
port: data?.port ?? 8096,
|
||||||
hostname: data?.ip,
|
useSsl: data?.useSsl,
|
||||||
port: data?.port ?? 8096,
|
urlBase: data?.urlBase || '',
|
||||||
useSsl: data?.useSsl,
|
jellyfinExternalUrl: data?.externalHostname || '',
|
||||||
urlBase: data?.urlBase || '',
|
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
|
||||||
jellyfinExternalUrl: data?.externalHostname || '',
|
apiKey: data?.apiKey,
|
||||||
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
|
}}
|
||||||
}}
|
validationSchema={JellyfinSettingsSchema}
|
||||||
validationSchema={JellyfinSettingsSchema}
|
onSubmit={async (values) => {
|
||||||
onSubmit={async (values) => {
|
try {
|
||||||
try {
|
const res = await fetch('/api/v1/settings/jellyfin', {
|
||||||
await axios.post('/api/v1/settings/jellyfin', {
|
method: 'POST',
|
||||||
ip: values.hostname,
|
headers: {
|
||||||
port: Number(values.port),
|
'Content-Type': 'application/json',
|
||||||
useSsl: values.useSsl,
|
},
|
||||||
urlBase: values.urlBase,
|
body: JSON.stringify({
|
||||||
externalHostname: values.jellyfinExternalUrl,
|
ip: values.hostname,
|
||||||
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
|
port: Number(values.port),
|
||||||
} as JellyfinSettings);
|
useSsl: values.useSsl,
|
||||||
|
urlBase: values.urlBase,
|
||||||
|
externalHostname: values.jellyfinExternalUrl,
|
||||||
|
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
|
||||||
|
apiKey: values.apiKey,
|
||||||
|
} as JellyfinSettings),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||||
|
|
||||||
addToast(
|
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;
|
||||||
errors,
|
try {
|
||||||
touched,
|
errorData = await e.cause?.text();
|
||||||
values,
|
errorData = JSON.parse(errorData);
|
||||||
setFieldValue,
|
} catch {
|
||||||
handleSubmit,
|
/* empty */
|
||||||
isSubmitting,
|
}
|
||||||
isValid,
|
if (errorData?.message === ApiErrorCode.InvalidUrl) {
|
||||||
}) => {
|
addToast(
|
||||||
return (
|
intl.formatMessage(messages.invalidurlerror, {
|
||||||
<form className="section" onSubmit={handleSubmit}>
|
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();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({
|
||||||
|
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)}
|
||||||
@@ -573,6 +622,29 @@ 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)}
|
||||||
@@ -593,75 +665,73 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
</>
|
||||||
<label htmlFor="jellyfinExternalUrl" className="text-label">
|
)}
|
||||||
{intl.formatMessage(messages.externalUrl)}
|
<div className="form-row">
|
||||||
</label>
|
<label htmlFor="jellyfinExternalUrl" className="text-label">
|
||||||
<div className="form-input-area">
|
{intl.formatMessage(messages.externalUrl)}
|
||||||
<div className="form-input-field">
|
</label>
|
||||||
<Field
|
<div className="form-input-area">
|
||||||
type="text"
|
<div className="form-input-field">
|
||||||
inputMode="url"
|
<Field
|
||||||
id="jellyfinExternalUrl"
|
type="text"
|
||||||
name="jellyfinExternalUrl"
|
inputMode="url"
|
||||||
/>
|
id="jellyfinExternalUrl"
|
||||||
</div>
|
name="jellyfinExternalUrl"
|
||||||
{errors.jellyfinExternalUrl &&
|
/>
|
||||||
touched.jellyfinExternalUrl && (
|
|
||||||
<div className="error">
|
|
||||||
{errors.jellyfinExternalUrl}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
{errors.jellyfinExternalUrl &&
|
||||||
<label
|
touched.jellyfinExternalUrl && (
|
||||||
htmlFor="jellyfinForgotPasswordUrl"
|
<div className="error">{errors.jellyfinExternalUrl}</div>
|
||||||
className="text-label"
|
)}
|
||||||
|
</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>
|
||||||
|
</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}
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.jellyfinForgotPasswordUrl)}
|
<ArrowDownOnSquareIcon />
|
||||||
</label>
|
<span>
|
||||||
<div className="form-input-area">
|
{isSubmitting
|
||||||
<div className="form-input-field">
|
? intl.formatMessage(globalMessages.saving)
|
||||||
<Field
|
: intl.formatMessage(globalMessages.save)}
|
||||||
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>
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</span>
|
||||||
</form>
|
</div>
|
||||||
);
|
</div>
|
||||||
}}
|
</form>
|
||||||
</Formik>
|
);
|
||||||
</>
|
}}
|
||||||
)}
|
</Formik>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ 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';
|
||||||
@@ -173,7 +172,10 @@ const SettingsJobs = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runJob = async (job: Job) => {
|
const runJob = async (job: Job) => {
|
||||||
await axios.post(`/api/v1/settings/jobs/${job.id}/run`);
|
const res = await fetch(`/api/v1/settings/jobs/${job.id}/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
addToast(
|
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),
|
||||||
@@ -187,7 +189,10 @@ const SettingsJobs = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cancelJob = async (job: Job) => {
|
const cancelJob = async (job: Job) => {
|
||||||
await axios.post(`/api/v1/settings/jobs/${job.id}/cancel`);
|
const res = await fetch(`/api/v1/settings/jobs/${job.id}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
addToast(
|
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),
|
||||||
@@ -201,7 +206,10 @@ const SettingsJobs = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const flushCache = async (cache: CacheItem) => {
|
const flushCache = async (cache: CacheItem) => {
|
||||||
await axios.post(`/api/v1/settings/cache/${cache.id}/flush`);
|
const res = await fetch(`/api/v1/settings/cache/${cache.id}/flush`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
addToast(
|
addToast(
|
||||||
intl.formatMessage(messages.cacheflushed, { cachename: cache.name }),
|
intl.formatMessage(messages.cacheflushed, { cachename: cache.name }),
|
||||||
{
|
{
|
||||||
@@ -228,12 +236,19 @@ const SettingsJobs = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
await axios.post(
|
const res = await fetch(
|
||||||
`/api/v1/settings/jobs/${jobModalState.job.id}/schedule`,
|
`/api/v1/settings/jobs/${jobModalState.job.id}/schedule`,
|
||||||
{
|
{
|
||||||
schedule: jobScheduleCron.join(' '),
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
schedule: jobScheduleCron.join(' '),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
addToast(intl.formatMessage(messages.jobScheduleEditSaved), {
|
addToast(intl.formatMessage(messages.jobScheduleEditSaved), {
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ 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';
|
||||||
@@ -87,7 +86,10 @@ const SettingsMain = () => {
|
|||||||
|
|
||||||
const regenerate = async () => {
|
const regenerate = async () => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/main/regenerate');
|
const res = await fetch('/api/v1/settings/main/regenerate', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
revalidate();
|
revalidate();
|
||||||
addToast(intl.formatMessage(messages.toastApiKeySuccess), {
|
addToast(intl.formatMessage(messages.toastApiKeySuccess), {
|
||||||
@@ -140,18 +142,25 @@ const SettingsMain = () => {
|
|||||||
validationSchema={MainSettingsSchema}
|
validationSchema={MainSettingsSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/main', {
|
const res = await fetch('/api/v1/settings/main', {
|
||||||
applicationTitle: values.applicationTitle,
|
method: 'POST',
|
||||||
applicationUrl: values.applicationUrl,
|
headers: {
|
||||||
csrfProtection: values.csrfProtection,
|
'Content-Type': 'application/json',
|
||||||
hideAvailable: values.hideAvailable,
|
},
|
||||||
locale: values.locale,
|
body: JSON.stringify({
|
||||||
region: values.region,
|
applicationTitle: values.applicationTitle,
|
||||||
originalLanguage: values.originalLanguage,
|
applicationUrl: values.applicationUrl,
|
||||||
partialRequestsEnabled: values.partialRequestsEnabled,
|
csrfProtection: values.csrfProtection,
|
||||||
trustProxy: values.trustProxy,
|
hideAvailable: values.hideAvailable,
|
||||||
cacheImages: values.cacheImages,
|
locale: values.locale,
|
||||||
|
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');
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ 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';
|
||||||
@@ -241,9 +240,15 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
|||||||
params.enable = activeLibraries.join(',');
|
params.enable = activeLibraries.join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
await axios.get('/api/v1/settings/plex/library', {
|
const searchParams = new URLSearchParams({
|
||||||
params,
|
sync: params.sync ? 'true' : 'false',
|
||||||
|
...(params.enable ? { enable: params.enable } : {}),
|
||||||
});
|
});
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/settings/plex/library?${searchParams.toString()}`
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
setIsSyncing(false);
|
setIsSyncing(false);
|
||||||
revalidate();
|
revalidate();
|
||||||
};
|
};
|
||||||
@@ -262,11 +267,12 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
|||||||
toastId = id;
|
toastId = id;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const response = await axios.get<PlexDevice[]>(
|
const res = await fetch('/api/v1/settings/plex/devices/servers');
|
||||||
'/api/v1/settings/plex/devices/servers'
|
if (!res.ok) throw new Error();
|
||||||
);
|
const data: PlexDevice[] = await res.json();
|
||||||
if (response.data) {
|
|
||||||
setAvailableServers(response.data);
|
if (data) {
|
||||||
|
setAvailableServers(data);
|
||||||
}
|
}
|
||||||
if (toastId) {
|
if (toastId) {
|
||||||
removeToast(toastId);
|
removeToast(toastId);
|
||||||
@@ -289,16 +295,30 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startScan = async () => {
|
const startScan = async () => {
|
||||||
await axios.post('/api/v1/settings/plex/sync', {
|
const res = await fetch('/api/v1/settings/plex/sync', {
|
||||||
start: true,
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
start: true,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
revalidateSync();
|
revalidateSync();
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelScan = async () => {
|
const cancelScan = async () => {
|
||||||
await axios.post('/api/v1/settings/plex/sync', {
|
const res = await fetch('/api/v1/settings/plex/sync', {
|
||||||
cancel: true,
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
cancel: true,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
revalidateSync();
|
revalidateSync();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -313,15 +333,19 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
|||||||
.join(',');
|
.join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
await axios.get('/api/v1/settings/plex/library', {
|
const searchParams = new URLSearchParams(params.enable ? params : {});
|
||||||
params,
|
const res = await fetch(
|
||||||
});
|
`/api/v1/settings/plex/library?${searchParams.toString()}`
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
} else {
|
} else {
|
||||||
await axios.get('/api/v1/settings/plex/library', {
|
const searchParams = new URLSearchParams({
|
||||||
params: {
|
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();
|
||||||
@@ -385,12 +409,19 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
|||||||
toastId = id;
|
toastId = id;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
await axios.post('/api/v1/settings/plex', {
|
const res = await fetch('/api/v1/settings/plex', {
|
||||||
ip: values.hostname,
|
method: 'POST',
|
||||||
port: Number(values.port),
|
headers: {
|
||||||
useSsl: values.useSsl,
|
'Content-Type': 'application/json',
|
||||||
webAppUrl: values.webAppUrl,
|
},
|
||||||
} as PlexSettings);
|
body: JSON.stringify({
|
||||||
|
ip: values.hostname,
|
||||||
|
port: Number(values.port),
|
||||||
|
useSsl: values.useSsl,
|
||||||
|
webAppUrl: values.webAppUrl,
|
||||||
|
} as PlexSettings),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
syncLibraries();
|
syncLibraries();
|
||||||
|
|
||||||
@@ -748,14 +779,27 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
|||||||
validationSchema={TautulliSettingsSchema}
|
validationSchema={TautulliSettingsSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/tautulli', {
|
const res = await fetch('/api/v1/settings/tautulli', {
|
||||||
hostname: values.tautulliHostname,
|
method: 'POST',
|
||||||
port: Number(values.tautulliPort),
|
headers: {
|
||||||
useSsl: values.tautulliUseSsl,
|
'Content-Type': 'application/json',
|
||||||
urlBase: values.tautulliUrlBase,
|
},
|
||||||
apiKey: values.tautulliApiKey,
|
body: JSON.stringify({
|
||||||
externalUrl: values.tautulliExternalUrl,
|
hostname: values.tautulliHostname,
|
||||||
} as TautulliSettings);
|
port: Number(values.tautulliPort),
|
||||||
|
useSsl: values.tautulliUseSsl,
|
||||||
|
urlBase: values.tautulliUrlBase,
|
||||||
|
apiKey: values.tautulliApiKey,
|
||||||
|
externalUrl: values.tautulliExternalUrl,
|
||||||
|
} as TautulliSettings),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Failed to fetch');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue with any necessary processing
|
||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
intl.formatMessage(messages.toastTautulliSettingsSuccess),
|
intl.formatMessage(messages.toastTautulliSettingsSuccess),
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import defineMessages from '@app/utils/defineMessages';
|
|||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
|
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
|
||||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||||
import axios from 'axios';
|
|
||||||
import { Fragment, useState } from 'react';
|
import { Fragment, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import useSWR, { mutate } from 'swr';
|
import useSWR, { mutate } from 'swr';
|
||||||
@@ -196,9 +195,14 @@ const SettingsServices = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deleteServer = async () => {
|
const deleteServer = async () => {
|
||||||
await axios.delete(
|
const res = await fetch(
|
||||||
`/api/v1/settings/${deleteServerModal.type}/${deleteServerModal.serverId}`
|
`/api/v1/settings/${deleteServerModal.type}/${deleteServerModal.serverId}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
setDeleteServerModal({ open: false, serverId: null, type: 'radarr' });
|
setDeleteServerModal({ open: false, serverId: null, type: 'radarr' });
|
||||||
revalidateRadarr();
|
revalidateRadarr();
|
||||||
revalidateSonarr();
|
revalidateSonarr();
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import defineMessages from '@app/utils/defineMessages';
|
|||||||
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
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 getConfig from 'next/config';
|
import getConfig from 'next/config';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
@@ -77,21 +76,28 @@ const SettingsUsers = () => {
|
|||||||
enableReinitialize
|
enableReinitialize
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/main', {
|
const res = await fetch('/api/v1/settings/main', {
|
||||||
localLogin: values.localLogin,
|
method: 'POST',
|
||||||
newPlexLogin: values.newPlexLogin,
|
headers: {
|
||||||
defaultQuotas: {
|
'Content-Type': 'application/json',
|
||||||
movie: {
|
|
||||||
quotaLimit: values.movieQuotaLimit,
|
|
||||||
quotaDays: values.movieQuotaDays,
|
|
||||||
},
|
|
||||||
tv: {
|
|
||||||
quotaLimit: values.tvQuotaLimit,
|
|
||||||
quotaDays: values.tvQuotaDays,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
defaultPermissions: values.defaultPermissions,
|
body: JSON.stringify({
|
||||||
|
localLogin: values.localLogin,
|
||||||
|
newPlexLogin: values.newPlexLogin,
|
||||||
|
defaultQuotas: {
|
||||||
|
movie: {
|
||||||
|
quotaLimit: values.movieQuotaLimit,
|
||||||
|
quotaDays: values.movieQuotaDays,
|
||||||
|
},
|
||||||
|
tv: {
|
||||||
|
quotaLimit: values.tvQuotaLimit,
|
||||||
|
quotaDays: values.tvQuotaDays,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultPermissions: values.defaultPermissions,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
mutate('/api/v1/settings/public');
|
mutate('/api/v1/settings/public');
|
||||||
|
|
||||||
addToast(intl.formatMessage(messages.toastSettingsSuccess), {
|
addToast(intl.formatMessage(messages.toastSettingsSuccess), {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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 { SonarrSettings } from '@server/lib/settings';
|
import type { SonarrSettings } 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';
|
||||||
@@ -177,19 +176,24 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
}) => {
|
}) => {
|
||||||
setIsTesting(true);
|
setIsTesting(true);
|
||||||
try {
|
try {
|
||||||
const response = await axios.post<TestResponse>(
|
const res = await fetch('/api/v1/settings/sonarr/test', {
|
||||||
'/api/v1/settings/sonarr/test',
|
method: 'POST',
|
||||||
{
|
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: TestResponse = await res.json();
|
||||||
|
|
||||||
setIsValidated(true);
|
setIsValidated(true);
|
||||||
setTestResponse(response.data);
|
setTestResponse(data);
|
||||||
if (initialLoad.current) {
|
if (initialLoad.current) {
|
||||||
addToast(intl.formatMessage(messages.toastSonarrTestSuccess), {
|
addToast(intl.formatMessage(messages.toastSonarrTestSuccess), {
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
@@ -306,12 +310,23 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
tagRequests: values.tagRequests,
|
tagRequests: values.tagRequests,
|
||||||
};
|
};
|
||||||
if (!sonarr) {
|
if (!sonarr) {
|
||||||
await axios.post('/api/v1/settings/sonarr', submission);
|
const res = await fetch('/api/v1/settings/sonarr', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(submission),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
} else {
|
} else {
|
||||||
await axios.put(
|
const res = await fetch(`/api/v1/settings/sonarr/${sonarr.id}`, {
|
||||||
`/api/v1/settings/sonarr/${sonarr.id}`,
|
method: 'PUT',
|
||||||
submission
|
headers: {
|
||||||
);
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(submission),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
}
|
}
|
||||||
|
|
||||||
onSave();
|
onSave();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import PlexLoginButton from '@app/components/PlexLoginButton';
|
import PlexLoginButton from '@app/components/PlexLoginButton';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import axios from 'axios';
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
@@ -25,9 +24,19 @@ const LoginWithPlex = ({ onComplete }: LoginWithPlexProps) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
const response = await axios.post('/api/v1/auth/plex', { authToken });
|
const res = await fetch('/api/v1/auth/plex', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
authToken,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
if (response.data?.id) {
|
if (data?.id) {
|
||||||
revalidate();
|
revalidate();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user