Compare commits
91 Commits
preview-fe
...
preview-ov
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c8fb4f4bb | ||
|
|
98ecdf16d2 | ||
|
|
a8bd51f8ab | ||
|
|
6ee96604ec | ||
|
|
aa3670322d | ||
|
|
38eaa30cee | ||
|
|
c0fce76db7 | ||
|
|
92341c8d06 | ||
|
|
cbcb71436d | ||
|
|
39a5ccb7f3 | ||
|
|
9b151feb4f | ||
|
|
fe5d016929 | ||
|
|
14f316a9a6 | ||
|
|
5c24e79b1d | ||
|
|
ba84212e68 | ||
|
|
f25b32aec8 | ||
|
|
5a13226877 | ||
|
|
694913c767 | ||
|
|
a2d2fd3c2a | ||
|
|
cb94ad5a2e | ||
|
|
2829c2548a | ||
|
|
64f4610b9f | ||
|
|
2d3b777daf | ||
|
|
cf59102ef9 | ||
|
|
ca838a00fa | ||
|
|
f2ed101e52 | ||
|
|
4b4eeb6ec7 | ||
|
|
d331798b28 | ||
|
|
f2b63156d1 | ||
|
|
326001c3ec | ||
|
|
0bbcfcbd5e | ||
|
|
32e0b129fe | ||
|
|
a2b3408c9a | ||
|
|
cbb1a74526 | ||
|
|
26c37ec067 | ||
|
|
4e48fdf2cb | ||
|
|
a351264b87 | ||
|
|
9de304d17a | ||
|
|
4945b54298 | ||
|
|
a0f80fe764 | ||
|
|
92ba26207d | ||
|
|
96e1d40304 | ||
|
|
a5d22ba5b8 | ||
|
|
f390da4866 | ||
|
|
edfd80444c | ||
|
|
2b05ffface | ||
|
|
818aa60aac | ||
|
|
ee7e91c7c9 | ||
|
|
45ef150e36 | ||
|
|
54cfeefe74 | ||
|
|
89e0a831ec | ||
|
|
e57d2654d1 | ||
|
|
a02a0dd176 | ||
|
|
7423bbbffc | ||
|
|
32343f23a3 | ||
|
|
15cb949f1f | ||
|
|
cfd1bc2535 | ||
|
|
80f63017ac | ||
|
|
0c7e652672 | ||
|
|
bd4da6d5fc | ||
|
|
12f908de7f | ||
|
|
61dcd8e487 | ||
|
|
9aee8887d3 | ||
|
|
2348f23f43 | ||
|
|
74a2d25f15 | ||
|
|
a2c2d261fc | ||
|
|
71acfb1b1f | ||
|
|
29a32d0391 | ||
|
|
f7be4789a2 | ||
|
|
181cb19048 | ||
|
|
32c77f9e94 | ||
|
|
b43c1e350e | ||
|
|
64453320d3 | ||
|
|
36d98a2681 | ||
|
|
d5f817e734 | ||
|
|
422085523e | ||
|
|
fccfca6ed0 | ||
|
|
3fc14c9e22 | ||
|
|
62dbde448c | ||
|
|
0116c13e06 | ||
|
|
c96ca6742e | ||
|
|
c80d9a853a | ||
|
|
6cea8bba59 | ||
|
|
2be9c7dcc1 | ||
|
|
5cc4389825 | ||
|
|
dd6dbf1de9 | ||
|
|
c600566ac0 | ||
|
|
4db1df2ba5 | ||
|
|
3a363ae1ff | ||
|
|
084e1b224e | ||
|
|
b36bb3fa58 |
@@ -403,6 +403,51 @@
|
|||||||
"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"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "M0NsTeRRR",
|
||||||
|
"name": "Ludovic Ortega",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/37785089?v=4",
|
||||||
|
"profile": "https://github.com/M0NsTeRRR",
|
||||||
|
"contributions": [
|
||||||
|
"security"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ config/logs/*
|
|||||||
config/*.json
|
config/*.json
|
||||||
dist
|
dist
|
||||||
Dockerfile*
|
Dockerfile*
|
||||||
docker-compose.yml
|
compose.yaml
|
||||||
docs
|
docs
|
||||||
LICENSE
|
LICENSE
|
||||||
node_modules
|
node_modules
|
||||||
|
|||||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -40,7 +40,7 @@ docs export-ignore
|
|||||||
.all-contributorsrc export-ignore
|
.all-contributorsrc export-ignore
|
||||||
.editorconfig export-ignore
|
.editorconfig export-ignore
|
||||||
Dockerfile.local export-ignore
|
Dockerfile.local export-ignore
|
||||||
docker-compose.yml export-ignore
|
compose.yaml export-ignore
|
||||||
stylelint.config.js export-ignore
|
stylelint.config.js export-ignore
|
||||||
|
|
||||||
public/os_logo_filled.png export-ignore
|
public/os_logo_filled.png export-ignore
|
||||||
|
|||||||
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1,2 +1,2 @@
|
|||||||
# Global code ownership
|
# Global code ownership
|
||||||
* @Fallenbagel
|
* @Fallenbagel @gauthier-th
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -1,6 +1,6 @@
|
|||||||
name: 🐛 Bug Report
|
name: 🐛 Bug Report
|
||||||
description: Report a problem
|
description: Report a problem
|
||||||
labels: ['type:bug', 'awaiting-triage']
|
labels: ['bug', 'awaiting triage']
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
2
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
@@ -1,6 +1,6 @@
|
|||||||
name: ✨ Feature Request
|
name: ✨ Feature Request
|
||||||
description: Suggest an idea
|
description: Suggest an idea
|
||||||
labels: ['type:enhancement', 'awaiting-triage']
|
labels: ['enhancement', 'awaiting triage']
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,6 +34,7 @@ yarn-error.log*
|
|||||||
# database
|
# database
|
||||||
config/db/*.sqlite3*
|
config/db/*.sqlite3*
|
||||||
config/settings.json
|
config/settings.json
|
||||||
|
config/settings.old.json
|
||||||
|
|
||||||
# logs
|
# logs
|
||||||
config/logs/*.log*
|
config/logs/*.log*
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
|||||||
4. Run the development environment:
|
4. Run the development environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm
|
pnpm install
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
|
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
|
||||||
|
|
||||||
5. Create your patch and test your changes.
|
5. Create your patch and test your changes.
|
||||||
|
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -8,10 +8,10 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/discord/952656177924300932" alt="Discord"></a>
|
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/discord/952656177924300932" alt="Discord"></a>
|
||||||
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
|
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></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="http://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/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-48-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,16 @@ 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>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/M0NsTeRRR"><img src="https://avatars.githubusercontent.com/u/37785089?v=4?s=100" width="100px;" alt="Ludovic Ortega"/><br /><sub><b>Ludovic Ortega</b></sub></a><br /><a href="#security-M0NsTeRRR" title="Security">🛡️</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
version: '3'
|
|
||||||
services:
|
services:
|
||||||
jellyseerr:
|
jellyseerr:
|
||||||
build:
|
build:
|
||||||
@@ -75,6 +75,7 @@
|
|||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"webhookUrl": "",
|
"webhookUrl": "",
|
||||||
|
"webhookRoleId": "",
|
||||||
"enableMentions": true
|
"enableMentions": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ location ^~ /jellyseerr {
|
|||||||
sub_filter '/api/v1' '/$app/api/v1';
|
sub_filter '/api/v1' '/$app/api/v1';
|
||||||
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
||||||
sub_filter '/images/' '/$app/images/';
|
sub_filter '/images/' '/$app/images/';
|
||||||
|
sub_filter '/imageproxy/' '/$app/imageproxy/';
|
||||||
|
sub_filter '/avatarproxy/' '/$app/avatarproxy/';
|
||||||
sub_filter '/android-' '/$app/android-';
|
sub_filter '/android-' '/$app/android-';
|
||||||
sub_filter '/apple-' '/$app/apple-';
|
sub_filter '/apple-' '/$app/apple-';
|
||||||
sub_filter '/favicon' '/$app/favicon';
|
sub_filter '/favicon' '/$app/favicon';
|
||||||
@@ -190,7 +192,7 @@ Caddy will automatically obtain and renew SSL certificates for your domain.
|
|||||||
|
|
||||||
## Traefik (v2)
|
## Traefik (v2)
|
||||||
|
|
||||||
Add the following labels to the Jellyseerr service in your `docker-compose.yml` file:
|
Add the following labels to the Jellyseerr service in your `compose.yaml` file:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ sidebar_position: 4
|
|||||||
|
|
||||||
# AUR (Arch User Repository)
|
# AUR (Arch User Repository)
|
||||||
|
|
||||||
|
:::note Disclaimer
|
||||||
|
This AUR package is not maintained by us but by a third party. Please refer to the maintainer for any issues.
|
||||||
|
:::
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
This method is not recommended for most users. It is intended for advanced users who are using Arch Linux or an Arch-based distribution.
|
This method is not recommended for most users. It is intended for advanced users who are using Arch Linux or an Arch-based distribution.
|
||||||
:::
|
:::
|
||||||
|
|||||||
@@ -12,49 +12,12 @@ import Tabs from '@theme/Tabs';
|
|||||||
import TabItem from '@theme/TabItem';
|
import TabItem from '@theme/TabItem';
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
<Tabs groupId="versions" queryString>
|
|
||||||
<TabItem value="latest" label="Latest">
|
|
||||||
- [Node.js 18.x](https://nodejs.org/en/download/)
|
|
||||||
- [Yarn 1.x](https://classic.yarnpkg.com/lang/en/docs/install)
|
|
||||||
- [Git](https://git-scm.com/downloads)
|
|
||||||
</TabItem>
|
|
||||||
|
|
||||||
<TabItem value="develop" label="Develop">
|
|
||||||
- [Node.js 20.x](https://nodejs.org/en/download/)
|
- [Node.js 20.x](https://nodejs.org/en/download/)
|
||||||
- [Pnpm 9.x](https://pnpm.io/installation)
|
- [Pnpm 9.x](https://pnpm.io/installation)
|
||||||
- [Git](https://git-scm.com/downloads)
|
- [Git](https://git-scm.com/downloads)
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
|
|
||||||
## Unix (Linux, macOS)
|
## Unix (Linux, macOS)
|
||||||
### Installation
|
### Installation
|
||||||
<Tabs groupId="versions" queryString>
|
|
||||||
<TabItem value="latest" label="latest">
|
|
||||||
1. Assuming you want the working directory to be `/opt/jellyseerr`, create the directory and navigate to it:
|
|
||||||
```bash
|
|
||||||
sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr
|
|
||||||
```
|
|
||||||
2. Clone the Jellyseerr repository and checkout the latest release:
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/Fallenbagel/jellyseerr.git
|
|
||||||
cd jellyseerr
|
|
||||||
git checkout main
|
|
||||||
```
|
|
||||||
3. Install the dependencies:
|
|
||||||
```bash
|
|
||||||
CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000
|
|
||||||
```
|
|
||||||
4. Build the project:
|
|
||||||
```bash
|
|
||||||
yarn build
|
|
||||||
```
|
|
||||||
5. Start Jellyseerr:
|
|
||||||
```bash
|
|
||||||
yarn start
|
|
||||||
```
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="develop" label="develop">
|
|
||||||
1. Assuming you want the working directory to be `/opt/jellyseerr`, create the directory and navigate to it:
|
1. Assuming you want the working directory to be `/opt/jellyseerr`, create the directory and navigate to it:
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr
|
sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr
|
||||||
@@ -77,9 +40,7 @@ pnpm build
|
|||||||
```bash
|
```bash
|
||||||
pnpm start
|
pnpm start
|
||||||
```
|
```
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
|
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
|
||||||
:::
|
:::
|
||||||
@@ -99,6 +60,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
|
||||||
@@ -231,32 +195,6 @@ pm2 status jellyseerr
|
|||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
### Installation
|
### Installation
|
||||||
<Tabs groupId="versions" queryString>
|
|
||||||
<TabItem value="latest" label="latest">
|
|
||||||
1. Assuming you want the working directory to be `C:\jellyseerr`, create the directory and navigate to it:
|
|
||||||
```powershell
|
|
||||||
mkdir C:\jellyseerr
|
|
||||||
cd C:\jellyseerr
|
|
||||||
```
|
|
||||||
2. Clone the Jellyseerr repository and checkout the latest release:
|
|
||||||
```powershell
|
|
||||||
git clone https://github.com/Fallenbagel/jellyseerr.git .
|
|
||||||
git checkout main
|
|
||||||
```
|
|
||||||
3. Install the dependencies:
|
|
||||||
```powershell
|
|
||||||
set CYPRESS_INSTALL_BINARY=0 && yarn install --frozen-lockfile --network-timeout 1000000
|
|
||||||
```
|
|
||||||
4. Build the project:
|
|
||||||
```powershell
|
|
||||||
yarn build
|
|
||||||
```
|
|
||||||
5. Start Jellyseerr:
|
|
||||||
```powershell
|
|
||||||
yarn start
|
|
||||||
```
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="develop" label="develop">
|
|
||||||
1. Assuming you want the working directory to be `C:\jellyseerr`, create the directory and navigate to it:
|
1. Assuming you want the working directory to be `C:\jellyseerr`, create the directory and navigate to it:
|
||||||
```powershell
|
```powershell
|
||||||
mkdir C:\jellyseerr
|
mkdir C:\jellyseerr
|
||||||
@@ -269,6 +207,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:
|
||||||
@@ -279,8 +218,6 @@ pnpm build
|
|||||||
```powershell
|
```powershell
|
||||||
pnpm start
|
pnpm start
|
||||||
```
|
```
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
You can add the environment variables to a `.env` file in the Jellyseerr directory.
|
You can add the environment variables to a `.env` file in the Jellyseerr directory.
|
||||||
@@ -308,11 +245,12 @@ node dist/index.js
|
|||||||
- Set the trigger to "When the computer starts"
|
- Set the trigger to "When the computer starts"
|
||||||
- Set the action to "Start a program"
|
- Set the action to "Start a program"
|
||||||
- Set the program/script to the path of the `start-jellyseerr.bat` file
|
- Set the program/script to the path of the `start-jellyseerr.bat` file
|
||||||
|
- Set the "Start in" to the jellyseerr directory.
|
||||||
- Click "Finish"
|
- Click "Finish"
|
||||||
|
|
||||||
Now, Jellyseerr will start when the computer boots up in the background.
|
Now, Jellyseerr will start when the computer boots up in the background.
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<TabItem value="nssm" label="NSSM">
|
<TabItem value="nssm" label="NSSM">
|
||||||
To run jellyseerr as a service:
|
To run jellyseerr as a service:
|
||||||
1. Download the [Non-Sucking Service Manager](https://nssm.cc/download)
|
1. Download the [Non-Sucking Service Manager](https://nssm.cc/download)
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ You could also use [diun](https://github.com/crazy-max/diun) to receive notifica
|
|||||||
For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/).
|
For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/).
|
||||||
|
|
||||||
#### Installation:
|
#### Installation:
|
||||||
Define the `jellyseerr` service in your `docker-compose.yml` as follows:
|
Define the `jellyseerr` service in your `compose.yaml` as follows:
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
services:
|
services:
|
||||||
@@ -94,17 +94,17 @@ If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable
|
|||||||
|
|
||||||
Then, start all services defined in the Compose file:
|
Then, start all services defined in the Compose file:
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Updating:
|
#### Updating:
|
||||||
Pull the latest image:
|
Pull the latest image:
|
||||||
```bash
|
```bash
|
||||||
docker-compose pull jellyseerr
|
docker compose pull jellyseerr
|
||||||
```
|
```
|
||||||
Then, restart all services defined in the Compose file:
|
Then, restart all services defined in the Compose file:
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
:::tip
|
:::tip
|
||||||
You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files.
|
You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const VersionMismatchWarning = () => {
|
|||||||
<>
|
<>
|
||||||
{!isUpToDate ? (
|
{!isUpToDate ? (
|
||||||
<Admonition type="warning">
|
<Admonition type="warning">
|
||||||
The <a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/servers/jellyseerr/default.nix#L14">upstream Jellyseerr Nix Package (v{nixpkgVersion})</a> is not <b>up-to-date</b>. If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>, you will need to <a href="#overriding-the-package">override the package derivation</a>.
|
The <a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/servers/jellyseerr/default.nix#L14">upstream Jellyseerr Nix Package (v{nixpkgVersion})</a> is not <b>up-to-date</b>. If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>, you will need to <a href="#overriding-the-package-derivation">override the package derivation</a>.
|
||||||
</Admonition>
|
</Admonition>
|
||||||
) : (
|
) : (
|
||||||
<Admonition type="success">
|
<Admonition type="success">
|
||||||
@@ -95,12 +95,12 @@ export const VersionMatch = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
offlineCache = pkgs.fetchYarnDeps {
|
offlineCache = pkgs.fetchYarnDeps {
|
||||||
sha256 = pkgs.lib.fakeSha256;
|
sha256 = pkgs.lib.fakeSha256;
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
const module = `{ config, pkgs, lib, ... }:
|
const module = `{ config, pkgs, lib, ... }:
|
||||||
|
|
||||||
with lib;
|
with lib;
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ Users can optionally opt-in to being mentioned in Discord notifications by confi
|
|||||||
|
|
||||||
You can find the webhook URL in the Discord application, at **Server Settings → Integrations → Webhooks**.
|
You can find the webhook URL in the Discord application, at **Server Settings → Integrations → Webhooks**.
|
||||||
|
|
||||||
|
### Notification Role ID (optional)
|
||||||
|
|
||||||
|
If a role ID is specified, it will be included in the webhook message. See [Discord role ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID).
|
||||||
|
|
||||||
### Bot Username (optional)
|
### Bot Username (optional)
|
||||||
|
|
||||||
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!
|
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ This is your Jellyseerr API key, which can be used to integrate Jellyseerr with
|
|||||||
|
|
||||||
If you need to generate a new API key for any reason, simply click the button to the right of the text box.
|
If you need to generate a new API key for any reason, simply click the button to the right of the text box.
|
||||||
|
|
||||||
|
If you want to set the API key, rather than letting it be randomly generated, you can use the API_KEY environment variable. Whatever that variable is set to will be your API key.
|
||||||
|
|
||||||
## Application Title
|
## Application Title
|
||||||
|
|
||||||
If you aren't a huge fan of the name "Jellyseerr" and would like to display something different to your users, you can customize the application title!
|
If you aren't a huge fan of the name "Jellyseerr" and would like to display something different to your users, you can customize the application title!
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Users can override the [global display language](/using-jellyseerr/settings/gene
|
|||||||
|
|
||||||
### Discover Region & Discover Language
|
### Discover Region & Discover Language
|
||||||
|
|
||||||
Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region-and-discover-language) to suit their own preferences.
|
Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region--discover-language) to suit their own preferences.
|
||||||
|
|
||||||
### Movie Request Limit & Series Request Limit
|
### Movie Request Limit & Series Request Limit
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,13 @@
|
|||||||
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: {
|
|
||||||
// Will be available on both server and client
|
|
||||||
JELLYFIN_TYPE: process.env.JELLYFIN_TYPE,
|
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{ hostname: 'gravatar.com' },
|
{ hostname: 'gravatar.com' },
|
||||||
{ hostname: 'image.tmdb.org' },
|
{ hostname: 'image.tmdb.org' },
|
||||||
{ hostname: '*', protocol: 'https' },
|
{ hostname: 'artworks.thetvdb.com' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ tags:
|
|||||||
description: Endpoints related to getting service (Radarr/Sonarr) details.
|
description: Endpoints related to getting service (Radarr/Sonarr) details.
|
||||||
- name: watchlist
|
- name: watchlist
|
||||||
description: Collection of media to watch later
|
description: Collection of media to watch later
|
||||||
|
- name: blacklist
|
||||||
|
description: Blacklisted media from discovery page.
|
||||||
servers:
|
servers:
|
||||||
- url: '{server}/api/v1'
|
- url: '{server}/api/v1'
|
||||||
variables:
|
variables:
|
||||||
@@ -46,6 +48,19 @@ servers:
|
|||||||
|
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
|
Blacklist:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
tmdbId:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
media:
|
||||||
|
$ref: '#/components/schemas/MediaInfo'
|
||||||
|
userId:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
Watchlist:
|
Watchlist:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1258,6 +1273,8 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
webhookUrl:
|
webhookUrl:
|
||||||
type: string
|
type: string
|
||||||
|
webhookRoleId:
|
||||||
|
type: string
|
||||||
enableMentions:
|
enableMentions:
|
||||||
type: boolean
|
type: boolean
|
||||||
SlackSettings:
|
SlackSettings:
|
||||||
@@ -1915,6 +1932,11 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
native_name:
|
native_name:
|
||||||
type: string
|
type: string
|
||||||
|
OverrideRule:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
cookieAuth:
|
cookieAuth:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
@@ -1973,6 +1995,9 @@ paths:
|
|||||||
appDataPath:
|
appDataPath:
|
||||||
type: string
|
type: string
|
||||||
example: /app/config
|
example: /app/config
|
||||||
|
appDataPermissions:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
/settings/main:
|
/settings/main:
|
||||||
get:
|
get:
|
||||||
summary: Get main settings
|
summary: Get main settings
|
||||||
@@ -2775,6 +2800,15 @@ paths:
|
|||||||
imageCount:
|
imageCount:
|
||||||
type: number
|
type: number
|
||||||
example: 123
|
example: 123
|
||||||
|
avatar:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
size:
|
||||||
|
type: number
|
||||||
|
example: 123456
|
||||||
|
imageCount:
|
||||||
|
type: number
|
||||||
|
example: 123
|
||||||
apiCaches:
|
apiCaches:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -3586,6 +3620,8 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
email:
|
email:
|
||||||
type: string
|
type: string
|
||||||
|
serverType:
|
||||||
|
type: number
|
||||||
required:
|
required:
|
||||||
- username
|
- username
|
||||||
- password
|
- password
|
||||||
@@ -3724,6 +3760,11 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
enum: [created, updated, requests, displayname]
|
enum: [created, updated, requests, displayname]
|
||||||
default: created
|
default: created
|
||||||
|
- in: query
|
||||||
|
name: q
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: A JSON array of all users
|
description: A JSON array of all users
|
||||||
@@ -4040,6 +4081,109 @@ paths:
|
|||||||
restricted:
|
restricted:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
|
/blacklist:
|
||||||
|
get:
|
||||||
|
summary: Returns blacklisted items
|
||||||
|
description: Returns list of all blacklisted media
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: take
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
example: 25
|
||||||
|
- in: query
|
||||||
|
name: skip
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
example: 0
|
||||||
|
- in: query
|
||||||
|
name: search
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
example: dune
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Blacklisted items returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
pageInfo:
|
||||||
|
$ref: '#/components/schemas/PageInfo'
|
||||||
|
results:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
user:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
example: 2024-04-21T01:55:44.000Z
|
||||||
|
id:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
mediaType:
|
||||||
|
type: string
|
||||||
|
example: movie
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
example: Dune
|
||||||
|
tmdbId:
|
||||||
|
type: number
|
||||||
|
example: 438631
|
||||||
|
post:
|
||||||
|
summary: Add media to blacklist
|
||||||
|
tags:
|
||||||
|
- blacklist
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Blacklist'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Item succesfully blacklisted
|
||||||
|
'412':
|
||||||
|
description: Item has already been blacklisted
|
||||||
|
/blacklist/{tmdbId}:
|
||||||
|
get:
|
||||||
|
summary: Get media from blacklist
|
||||||
|
tags:
|
||||||
|
- blacklist
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: tmdbId
|
||||||
|
description: tmdbId ID
|
||||||
|
required: true
|
||||||
|
example: '1'
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Blacklist details in JSON
|
||||||
|
delete:
|
||||||
|
summary: Remove media from blacklist
|
||||||
|
tags:
|
||||||
|
- blacklist
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: tmdbId
|
||||||
|
description: tmdbId ID
|
||||||
|
required: true
|
||||||
|
example: '1'
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Succesfully removed media item
|
||||||
/watchlist:
|
/watchlist:
|
||||||
post:
|
post:
|
||||||
summary: Add media to watchlist
|
summary: Add media to watchlist
|
||||||
@@ -4862,6 +5006,11 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
example: 8|9
|
example: 8|9
|
||||||
|
- in: query
|
||||||
|
name: status
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 3|4
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Results
|
description: Results
|
||||||
@@ -6817,6 +6966,68 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/WatchProviderDetails'
|
$ref: '#/components/schemas/WatchProviderDetails'
|
||||||
|
/overrideRule:
|
||||||
|
get:
|
||||||
|
summary: Get override rules
|
||||||
|
description: Returns a list of all override rules with their conditions and settings
|
||||||
|
tags:
|
||||||
|
- overriderule
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Override rules returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/OverrideRule'
|
||||||
|
post:
|
||||||
|
summary: Create override rule
|
||||||
|
description: Creates a new Override Rule from the request body.
|
||||||
|
tags:
|
||||||
|
- overriderule
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 'Values were successfully created'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/OverrideRule'
|
||||||
|
/overrideRule/{ruleId}:
|
||||||
|
put:
|
||||||
|
summary: Update override rule
|
||||||
|
description: Updates an Override Rule from the request body.
|
||||||
|
tags:
|
||||||
|
- overriderule
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 'Values were successfully updated'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/OverrideRule'
|
||||||
|
delete:
|
||||||
|
summary: Delete override rule by ID
|
||||||
|
description: Deletes the override rule with the provided ruleId.
|
||||||
|
tags:
|
||||||
|
- overriderule
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: ruleId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Override rule successfully deleted
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OverrideRule'
|
||||||
security:
|
security:
|
||||||
- cookieAuth: []
|
- cookieAuth: []
|
||||||
- apiKey: []
|
- apiKey: []
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -64,6 +62,7 @@
|
|||||||
"formik": "^2.4.6",
|
"formik": "^2.4.6",
|
||||||
"gravatar-url": "3.1.0",
|
"gravatar-url": "3.1.0",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
|
"mime": "3",
|
||||||
"next": "^14.2.4",
|
"next": "^14.2.4",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"node-gyp": "9.3.1",
|
"node-gyp": "9.3.1",
|
||||||
@@ -94,7 +93,8 @@
|
|||||||
"sqlite3": "5.1.4",
|
"sqlite3": "5.1.4",
|
||||||
"swagger-ui-express": "4.6.2",
|
"swagger-ui-express": "4.6.2",
|
||||||
"swr": "2.2.5",
|
"swr": "2.2.5",
|
||||||
"typeorm": "0.3.12",
|
"typeorm": "0.3.11",
|
||||||
|
"undici": "^6.20.1",
|
||||||
"web-push": "3.5.0",
|
"web-push": "3.5.0",
|
||||||
"winston": "3.8.2",
|
"winston": "3.8.2",
|
||||||
"winston-daily-rotate-file": "4.7.1",
|
"winston-daily-rotate-file": "4.7.1",
|
||||||
@@ -121,7 +121,8 @@
|
|||||||
"@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/mime": "3",
|
||||||
|
"@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",
|
||||||
|
|||||||
12865
pnpm-lock.yaml
generated
12865
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,101 @@ 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;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
|
||||||
|
this.defaultHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
...((url.username || url.password) && {
|
||||||
|
Authorization: `Basic ${Buffer.from(
|
||||||
|
`${url.username}:${url.password}`
|
||||||
|
).toString('base64')}`,
|
||||||
|
}),
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (url.username || url.password) {
|
||||||
|
url.username = '';
|
||||||
|
url.password = '';
|
||||||
|
baseUrl = url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
|
this.params = params;
|
||||||
this.cache = options.nodeCache;
|
this.cache = options.nodeCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async get<T>(
|
protected async get<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
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 && ttl !== 0) {
|
||||||
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 +113,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 && ttl !== 0) {
|
||||||
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 && ttl !== 0) {
|
||||||
|
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resData;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async delete<T>(
|
||||||
|
endpoint: string,
|
||||||
|
params?: Record<string, string>,
|
||||||
|
config?: RequestInit
|
||||||
|
): Promise<T> {
|
||||||
|
const url = this.formatUrl(endpoint, params);
|
||||||
|
const response = await this.fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
...config,
|
||||||
|
headers: {
|
||||||
|
...this.defaultHeaders,
|
||||||
|
...config?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
||||||
|
{
|
||||||
|
cause: response,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async getRolling<T>(
|
protected async getRolling<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
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 +234,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 +318,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 }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -143,39 +138,38 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
try {
|
try {
|
||||||
return await authenticate(true);
|
return await authenticate(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug(`Failed to authenticate with headers: ${e.message}`, {
|
logger.debug('Failed to authenticate with headers', {
|
||||||
label: 'Jellyfin API',
|
label: 'Jellyfin API',
|
||||||
|
error: e.cause.message ?? e.cause.statusText,
|
||||||
ip: ClientIP,
|
ip: ClientIP,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!e.cause.status) {
|
||||||
|
throw new ApiError(404, ApiErrorCode.InvalidUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.cause.status === 401) {
|
||||||
|
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await authenticate(false);
|
return await authenticate(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const status = e.response?.status;
|
if (e.cause.status === 401) {
|
||||||
|
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
|
||||||
const networkErrorCodes = new Set([
|
|
||||||
'ECONNREFUSED',
|
|
||||||
'EHOSTUNREACH',
|
|
||||||
'ENOTFOUND',
|
|
||||||
'ETIMEDOUT',
|
|
||||||
'ECONNRESET',
|
|
||||||
'EADDRINUSE',
|
|
||||||
'ENETDOWN',
|
|
||||||
'ENETUNREACH',
|
|
||||||
'EPIPE',
|
|
||||||
'ECONNABORTED',
|
|
||||||
'EPROTO',
|
|
||||||
'EHOSTDOWN',
|
|
||||||
'EAI_AGAIN',
|
|
||||||
'ERR_INVALID_URL',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (networkErrorCodes.has(e.code) || status === 404) {
|
|
||||||
throw new ApiError(status, ApiErrorCode.InvalidUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ApiError(status, ApiErrorCode.InvalidCredentials);
|
logger.error(
|
||||||
|
'Something went wrong while authenticating with the Jellyfin server',
|
||||||
|
{
|
||||||
|
label: 'Jellyfin API',
|
||||||
|
error: e.cause.message ?? e.cause.statusText,
|
||||||
|
ip: ClientIP,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new ApiError(e.cause.status, ApiErrorCode.Unknown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +184,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,11 +197,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return serverResponse.ServerName;
|
return serverResponse.ServerName;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting the server name from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,11 +212,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return { users: userReponse };
|
return { users: userReponse };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting the account from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,11 +228,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return userReponse;
|
return userReponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting the account from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,8 +252,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return this.mapLibraries(mediaFolderResponse.Items);
|
return this.mapLibraries(mediaFolderResponse.Items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting libraries from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{
|
||||||
|
label: 'Jellyfin API',
|
||||||
|
error: e.cause.message ?? e.cause.statusText,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
@@ -296,7 +293,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(
|
||||||
@@ -304,28 +310,32 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting library content from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
);
|
);
|
||||||
|
|
||||||
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;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting library content from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,16 +350,16 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting library content from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
);
|
);
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,11 +370,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return seasonResponse.Items;
|
return seasonResponse.Items;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting the list of seasons from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +384,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(
|
||||||
@@ -382,8 +395,25 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting the list of episodes from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
|
);
|
||||||
|
|
||||||
|
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 from the Jellyfin server',
|
||||||
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ class PlexAPI {
|
|||||||
settings.plex.libraries = [];
|
settings.plex.libraries = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.save();
|
await settings.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getLibraryContents(
|
public async getLibraryContents(
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -185,7 +182,7 @@ class RottenTomatoes extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tvshow) {
|
if (!tvshow || !tvshow.rottenTomatoes) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,15 @@ 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`,
|
||||||
{
|
{
|
||||||
params: {
|
includeEpisode: 'true',
|
||||||
includeEpisode: true,
|
},
|
||||||
},
|
0
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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 +175,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,25 +187,34 @@ 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}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async refreshMonitoredDownloads(): Promise<void> {
|
||||||
|
await this.runCommand('RefreshMonitoredDownloads', {});
|
||||||
|
}
|
||||||
|
|
||||||
protected async runCommand(
|
protected async runCommand(
|
||||||
commandName: string,
|
commandName: string,
|
||||||
options: Record<string, unknown>
|
options: Record<string, unknown>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.axios.post(`/command`, {
|
await this.post(
|
||||||
name: commandName,
|
`/command`,
|
||||||
...options,
|
{
|
||||||
});
|
name: commandName,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
0
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
|
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
|
|
||||||
public getMovies = async (): Promise<RadarrMovie[]> => {
|
public getMovies = async (): Promise<RadarrMovie[]> => {
|
||||||
try {
|
try {
|
||||||
const 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');
|
||||||
}
|
}
|
||||||
@@ -303,10 +303,10 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.runCommand('SeriesSearch', { seriesId });
|
await this.runCommand('MissingEpisodeSearch', { seriesId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while executing Sonarr series search.',
|
'Something went wrong while executing Sonarr missing episode search.',
|
||||||
{
|
{
|
||||||
label: 'Sonarr API',
|
label: 'Sonarr API',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ interface DiscoverTvOptions {
|
|||||||
sortBy?: SortOptions;
|
sortBy?: SortOptions;
|
||||||
watchRegion?: string;
|
watchRegion?: string;
|
||||||
watchProviders?: string;
|
watchProviders?: string;
|
||||||
|
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
|
||||||
}
|
}
|
||||||
|
|
||||||
class TheMovieDb extends ExternalAPI {
|
class TheMovieDb extends ExternalAPI {
|
||||||
@@ -112,8 +113,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
{
|
{
|
||||||
nodeCache: cacheManager.getCache('tmdb').data,
|
nodeCache: cacheManager.getCache('tmdb').data,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
maxRequests: 20,
|
|
||||||
maxRPS: 50,
|
maxRPS: 50,
|
||||||
|
id: 'tmdb',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -129,7 +130,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 +156,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 +183,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 +210,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
}): Promise<TmdbPersonDetails> => {
|
}): Promise<TmdbPersonDetails> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, {
|
const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, {
|
||||||
params: { language },
|
language,
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -230,7 +230,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbPersonCombinedCredits>(
|
const data = await this.get<TmdbPersonCombinedCredits>(
|
||||||
`/person/${personId}/combined_credits`,
|
`/person/${personId}/combined_credits`,
|
||||||
{
|
{
|
||||||
params: { language },
|
language,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -253,11 +253,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 +277,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 +303,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 +327,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 +351,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 +375,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 +399,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 +423,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 +465,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;
|
||||||
@@ -542,6 +524,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
voteCountLte,
|
voteCountLte,
|
||||||
watchProviders,
|
watchProviders,
|
||||||
watchRegion,
|
watchRegion,
|
||||||
|
withStatus,
|
||||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||||
try {
|
try {
|
||||||
const defaultFutureDate = new Date(
|
const defaultFutureDate = new Date(
|
||||||
@@ -555,40 +538,41 @@ 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 || '',
|
||||||
|
with_status: withStatus || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -608,12 +592,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 +618,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 +641,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 +662,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 +691,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 +782,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 +855,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 +867,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 +902,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 +914,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 +969,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 +992,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 +1013,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 +1037,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 +1062,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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export enum ApiErrorCode {
|
|||||||
InvalidUrl = 'INVALID_URL',
|
InvalidUrl = 'INVALID_URL',
|
||||||
InvalidCredentials = 'INVALID_CREDENTIALS',
|
InvalidCredentials = 'INVALID_CREDENTIALS',
|
||||||
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
|
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
|
||||||
|
InvalidEmail = 'INVALID_EMAIL',
|
||||||
NotAdmin = 'NOT_ADMIN',
|
NotAdmin = 'NOT_ADMIN',
|
||||||
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
|
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
|
||||||
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ export enum MediaStatus {
|
|||||||
PROCESSING,
|
PROCESSING,
|
||||||
PARTIALLY_AVAILABLE,
|
PARTIALLY_AVAILABLE,
|
||||||
AVAILABLE,
|
AVAILABLE,
|
||||||
|
BLACKLISTED,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,8 @@ export enum MediaServerType {
|
|||||||
EMBY,
|
EMBY,
|
||||||
NOT_CONFIGURED,
|
NOT_CONFIGURED,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ServerType {
|
||||||
|
JELLYFIN = 'Jellyfin',
|
||||||
|
EMBY = 'Emby',
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export enum UserType {
|
|||||||
PLEX = 1,
|
PLEX = 1,
|
||||||
LOCAL = 2,
|
LOCAL = 2,
|
||||||
JELLYFIN = 3,
|
JELLYFIN = 3,
|
||||||
|
EMBY = 4,
|
||||||
}
|
}
|
||||||
|
|||||||
95
server/entity/Blacklist.ts
Normal file
95
server/entity/Blacklist.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { MediaStatus, type MediaType } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import Media from '@server/entity/Media';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
OneToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
@Unique(['tmdbId'])
|
||||||
|
export class Blacklist implements BlacklistItem {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
public id: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar' })
|
||||||
|
public mediaType: MediaType;
|
||||||
|
|
||||||
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
@Index()
|
||||||
|
public tmdbId: number;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, (user) => user.id, {
|
||||||
|
eager: true,
|
||||||
|
})
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@OneToOne(() => Media, (media) => media.blacklist, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
@JoinColumn()
|
||||||
|
public media: Media;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
public createdAt: Date;
|
||||||
|
|
||||||
|
constructor(init?: Partial<Blacklist>) {
|
||||||
|
Object.assign(this, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async addToBlacklist({
|
||||||
|
blacklistRequest,
|
||||||
|
}: {
|
||||||
|
blacklistRequest: {
|
||||||
|
mediaType: MediaType;
|
||||||
|
title?: ZodOptional<ZodString>['_output'];
|
||||||
|
tmdbId: ZodNumber['_output'];
|
||||||
|
};
|
||||||
|
}): Promise<void> {
|
||||||
|
const blacklist = new this({
|
||||||
|
...blacklistRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
let media = await mediaRepository.findOne({
|
||||||
|
where: {
|
||||||
|
tmdbId: blacklistRequest.tmdbId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const blacklistRepository = getRepository(this);
|
||||||
|
|
||||||
|
await blacklistRepository.save(blacklist);
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
media = new Media({
|
||||||
|
tmdbId: blacklistRequest.tmdbId,
|
||||||
|
status: MediaStatus.BLACKLISTED,
|
||||||
|
status4k: MediaStatus.BLACKLISTED,
|
||||||
|
mediaType: blacklistRequest.mediaType,
|
||||||
|
blacklist: Promise.resolve(blacklist),
|
||||||
|
});
|
||||||
|
|
||||||
|
await mediaRepository.save(media);
|
||||||
|
} else {
|
||||||
|
media.blacklist = Promise.resolve(blacklist);
|
||||||
|
media.status = MediaStatus.BLACKLISTED;
|
||||||
|
media.status4k = MediaStatus.BLACKLISTED;
|
||||||
|
|
||||||
|
await mediaRepository.save(media);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import SonarrAPI from '@server/api/servarr/sonarr';
|
|||||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
|
import { Blacklist } from '@server/entity/Blacklist';
|
||||||
import type { User } from '@server/entity/User';
|
import type { User } from '@server/entity/User';
|
||||||
import { Watchlist } from '@server/entity/Watchlist';
|
import { Watchlist } from '@server/entity/Watchlist';
|
||||||
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
Entity,
|
Entity,
|
||||||
Index,
|
Index,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
|
OneToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
@@ -66,7 +68,7 @@ class Media {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
where: { tmdbId: id, mediaType },
|
where: { tmdbId: id, mediaType: mediaType },
|
||||||
relations: { requests: true, issues: true },
|
relations: { requests: true, issues: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,6 +118,9 @@ class Media {
|
|||||||
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
|
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
|
||||||
public issues: Issue[];
|
public issues: Issue[];
|
||||||
|
|
||||||
|
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
|
||||||
|
public blacklist: Promise<Blacklist>;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@@ -211,9 +216,10 @@ class Media {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const pageName =
|
const pageName =
|
||||||
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
|
getSettings().main.mediaServerType == MediaServerType.EMBY
|
||||||
|
? 'item'
|
||||||
|
: 'details';
|
||||||
const { serverId, externalHostname } = getSettings().jellyfin;
|
const { serverId, externalHostname } = getSettings().jellyfin;
|
||||||
|
|
||||||
const jellyfinHost =
|
const jellyfinHost =
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
@@ -223,7 +229,7 @@ class Media {
|
|||||||
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
||||||
}
|
}
|
||||||
if (this.jellyfinMediaId4k) {
|
if (this.jellyfinMediaId4k) {
|
||||||
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
MediaType,
|
MediaType,
|
||||||
} from '@server/constants/media';
|
} from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
|
import OverrideRule from '@server/entity/OverrideRule';
|
||||||
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
|
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
|
||||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
@@ -40,6 +41,7 @@ export class RequestPermissionError extends Error {}
|
|||||||
export class QuotaRestrictedError extends Error {}
|
export class QuotaRestrictedError extends Error {}
|
||||||
export class DuplicateMediaRequestError extends Error {}
|
export class DuplicateMediaRequestError extends Error {}
|
||||||
export class NoSeasonsAvailableError extends Error {}
|
export class NoSeasonsAvailableError extends Error {}
|
||||||
|
export class BlacklistedMediaError extends Error {}
|
||||||
|
|
||||||
type MediaRequestOptions = {
|
type MediaRequestOptions = {
|
||||||
isAutoRequest?: boolean;
|
isAutoRequest?: boolean;
|
||||||
@@ -143,6 +145,16 @@ export class MediaRequest {
|
|||||||
mediaType: requestBody.mediaType,
|
mediaType: requestBody.mediaType,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
if (media.status === MediaStatus.BLACKLISTED) {
|
||||||
|
logger.warn('Request for media blocked due to being blacklisted', {
|
||||||
|
tmdbId: tmdbMedia.id,
|
||||||
|
mediaType: requestBody.mediaType,
|
||||||
|
label: 'Media Request',
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new BlacklistedMediaError('This media is blacklisted.');
|
||||||
|
}
|
||||||
|
|
||||||
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
|
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
|
||||||
media.status = MediaStatus.PENDING;
|
media.status = MediaStatus.PENDING;
|
||||||
}
|
}
|
||||||
@@ -702,48 +714,6 @@ export class MediaRequest {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let rootFolder = radarrSettings.activeDirectory;
|
|
||||||
let qualityProfile = radarrSettings.activeProfileId;
|
|
||||||
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.rootFolder &&
|
|
||||||
this.rootFolder !== '' &&
|
|
||||||
this.rootFolder !== radarrSettings.activeDirectory
|
|
||||||
) {
|
|
||||||
rootFolder = this.rootFolder;
|
|
||||||
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.profileId &&
|
|
||||||
this.profileId !== radarrSettings.activeProfileId
|
|
||||||
) {
|
|
||||||
qualityProfile = this.profileId;
|
|
||||||
logger.info(
|
|
||||||
`Request has an override quality profile ID: ${qualityProfile}`,
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.tags && !isEqual(this.tags, radarrSettings.tags)) {
|
|
||||||
tags = this.tags;
|
|
||||||
logger.info(`Request has override tags`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
tagIds: tags,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
const radarr = new RadarrAPI({
|
const radarr = new RadarrAPI({
|
||||||
apiKey: radarrSettings.apiKey,
|
apiKey: radarrSettings.apiKey,
|
||||||
@@ -764,6 +734,151 @@ export class MediaRequest {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let rootFolder = radarrSettings.activeDirectory;
|
||||||
|
let qualityProfile = radarrSettings.activeProfileId;
|
||||||
|
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
|
||||||
|
|
||||||
|
const overrideRuleRepository = getRepository(OverrideRule);
|
||||||
|
const overrideRules = await overrideRuleRepository.find({
|
||||||
|
where: { radarrServiceId: radarrSettings.id },
|
||||||
|
});
|
||||||
|
const appliedOverrideRules = overrideRules.filter((rule) => {
|
||||||
|
if (
|
||||||
|
rule.users &&
|
||||||
|
!rule.users
|
||||||
|
.split(',')
|
||||||
|
.some((userId) => Number(userId) === this.requestedBy.id)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rule.genre &&
|
||||||
|
!rule.genre
|
||||||
|
.split(',')
|
||||||
|
.some((genreId) =>
|
||||||
|
movie.genres.map((genre) => genre.id).includes(Number(genreId))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rule.language &&
|
||||||
|
!rule.language
|
||||||
|
.split('|')
|
||||||
|
.some((languageId) => languageId === movie.original_language)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rule.keywords &&
|
||||||
|
!rule.keywords
|
||||||
|
.split(',')
|
||||||
|
.some((keywordId) =>
|
||||||
|
movie.keywords.keywords
|
||||||
|
.map((keyword) => keyword.id)
|
||||||
|
.includes(Number(keywordId))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.rootFolder &&
|
||||||
|
this.rootFolder !== '' &&
|
||||||
|
this.rootFolder !== radarrSettings.activeDirectory
|
||||||
|
) {
|
||||||
|
rootFolder = this.rootFolder;
|
||||||
|
logger.info(
|
||||||
|
`Request has a manually overriden root folder: ${rootFolder}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const overrideRootFolder = appliedOverrideRules.find(
|
||||||
|
(rule) => rule.rootFolder
|
||||||
|
)?.rootFolder;
|
||||||
|
if (overrideRootFolder) {
|
||||||
|
rootFolder = overrideRootFolder;
|
||||||
|
this.rootFolder = rootFolder;
|
||||||
|
logger.info(
|
||||||
|
`Request has an override root folder from override rules: ${rootFolder}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.profileId &&
|
||||||
|
this.profileId !== radarrSettings.activeProfileId
|
||||||
|
) {
|
||||||
|
qualityProfile = this.profileId;
|
||||||
|
logger.info(
|
||||||
|
`Request has a manually overriden quality profile ID: ${qualityProfile}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const overrideProfileId = appliedOverrideRules.find(
|
||||||
|
(rule) => rule.profileId
|
||||||
|
)?.profileId;
|
||||||
|
if (overrideProfileId) {
|
||||||
|
qualityProfile = overrideProfileId;
|
||||||
|
this.profileId = qualityProfile;
|
||||||
|
logger.info(
|
||||||
|
`Request has an override quality profile ID from override rules: ${qualityProfile}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.tags && !isEqual(this.tags, radarrSettings.tags)) {
|
||||||
|
tags = this.tags;
|
||||||
|
logger.info(`Request has manually overriden tags`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
tagIds: tags,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const overrideTags = appliedOverrideRules.find(
|
||||||
|
(rule) => rule.tags
|
||||||
|
)?.tags;
|
||||||
|
if (overrideTags) {
|
||||||
|
tags = [
|
||||||
|
...new Set([
|
||||||
|
...tags,
|
||||||
|
...overrideTags.split(',').map((tag) => Number(tag)),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
this.tags = tags;
|
||||||
|
logger.info(`Request has override tags from override rules`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
tagIds: tags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
requestRepository.save(this);
|
||||||
|
|
||||||
if (radarrSettings.tagRequests) {
|
if (radarrSettings.tagRequests) {
|
||||||
let userTag = (await radarr.getTags()).find((v) =>
|
let userTag = (await radarr.getTags()).find((v) =>
|
||||||
v.label.startsWith(this.requestedBy.id + ' - ')
|
v.label.startsWith(this.requestedBy.id + ' - ')
|
||||||
@@ -805,7 +920,6 @@ export class MediaRequest {
|
|||||||
mediaId: this.media.id,
|
mediaId: this.media.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
this.status = MediaRequestStatus.APPROVED;
|
this.status = MediaRequestStatus.APPROVED;
|
||||||
await requestRepository.save(this);
|
await requestRepository.save(this);
|
||||||
return;
|
return;
|
||||||
@@ -845,8 +959,6 @@ export class MediaRequest {
|
|||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
})
|
})
|
||||||
.catch(async () => {
|
.catch(async () => {
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
|
|
||||||
this.status = MediaRequestStatus.FAILED;
|
this.status = MediaRequestStatus.FAILED;
|
||||||
requestRepository.save(this);
|
requestRepository.save(this);
|
||||||
|
|
||||||
@@ -946,6 +1058,7 @@ export class MediaRequest {
|
|||||||
throw new Error('Media data not found');
|
throw new Error('Media data not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
if (
|
if (
|
||||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||||
) {
|
) {
|
||||||
@@ -955,7 +1068,6 @@ export class MediaRequest {
|
|||||||
mediaId: this.media.id,
|
mediaId: this.media.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
this.status = MediaRequestStatus.APPROVED;
|
this.status = MediaRequestStatus.APPROVED;
|
||||||
await requestRepository.save(this);
|
await requestRepository.save(this);
|
||||||
return;
|
return;
|
||||||
@@ -970,7 +1082,6 @@ export class MediaRequest {
|
|||||||
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
||||||
|
|
||||||
if (!tvdbId) {
|
if (!tvdbId) {
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
await mediaRepository.remove(media);
|
await mediaRepository.remove(media);
|
||||||
await requestRepository.remove(this);
|
await requestRepository.remove(this);
|
||||||
throw new Error('TVDB ID not found');
|
throw new Error('TVDB ID not found');
|
||||||
@@ -1008,29 +1119,110 @@ export class MediaRequest {
|
|||||||
? [...sonarrSettings.tags]
|
? [...sonarrSettings.tags]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const overrideRuleRepository = getRepository(OverrideRule);
|
||||||
|
const overrideRules = await overrideRuleRepository.find({
|
||||||
|
where: { sonarrServiceId: sonarrSettings.id },
|
||||||
|
});
|
||||||
|
const appliedOverrideRules = overrideRules.filter((rule) => {
|
||||||
|
if (
|
||||||
|
rule.users &&
|
||||||
|
!rule.users
|
||||||
|
.split(',')
|
||||||
|
.some((userId) => Number(userId) === this.requestedBy.id)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rule.genre &&
|
||||||
|
!rule.genre
|
||||||
|
.split(',')
|
||||||
|
.some((genreId) =>
|
||||||
|
series.genres.map((genre) => genre.id).includes(Number(genreId))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rule.language &&
|
||||||
|
!rule.language
|
||||||
|
.split('|')
|
||||||
|
.some((languageId) => languageId === series.original_language)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rule.keywords &&
|
||||||
|
!rule.keywords
|
||||||
|
.split(',')
|
||||||
|
.some((keywordId) =>
|
||||||
|
series.keywords.results
|
||||||
|
.map((keyword) => keyword.id)
|
||||||
|
.includes(Number(keywordId))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.rootFolder &&
|
this.rootFolder &&
|
||||||
this.rootFolder !== '' &&
|
this.rootFolder !== '' &&
|
||||||
this.rootFolder !== rootFolder
|
this.rootFolder !== rootFolder
|
||||||
) {
|
) {
|
||||||
rootFolder = this.rootFolder;
|
rootFolder = this.rootFolder;
|
||||||
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.profileId && this.profileId !== qualityProfile) {
|
|
||||||
qualityProfile = this.profileId;
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Request has an override quality profile ID: ${qualityProfile}`,
|
`Request has a manually overriden root folder: ${rootFolder}`,
|
||||||
{
|
{
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
requestId: this.id,
|
requestId: this.id,
|
||||||
mediaId: this.media.id,
|
mediaId: this.media.id,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
const overrideRootFolder = appliedOverrideRules.find(
|
||||||
|
(rule) => rule.rootFolder
|
||||||
|
)?.rootFolder;
|
||||||
|
if (overrideRootFolder) {
|
||||||
|
rootFolder = overrideRootFolder;
|
||||||
|
this.rootFolder = rootFolder;
|
||||||
|
logger.info(
|
||||||
|
`Request has an override root folder from override rules: ${rootFolder}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.profileId && this.profileId !== qualityProfile) {
|
||||||
|
qualityProfile = this.profileId;
|
||||||
|
logger.info(
|
||||||
|
`Request has a manually overriden quality profile ID: ${qualityProfile}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const overrideProfileId = appliedOverrideRules.find(
|
||||||
|
(rule) => rule.profileId
|
||||||
|
)?.profileId;
|
||||||
|
if (overrideProfileId) {
|
||||||
|
qualityProfile = overrideProfileId;
|
||||||
|
this.profileId = qualityProfile;
|
||||||
|
logger.info(
|
||||||
|
`Request has an override quality profile ID from override rules: ${qualityProfile}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -1050,12 +1242,31 @@ export class MediaRequest {
|
|||||||
|
|
||||||
if (this.tags && !isEqual(this.tags, tags)) {
|
if (this.tags && !isEqual(this.tags, tags)) {
|
||||||
tags = this.tags;
|
tags = this.tags;
|
||||||
logger.info(`Request has override tags`, {
|
logger.info(`Request has manually overriden tags`, {
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
requestId: this.id,
|
requestId: this.id,
|
||||||
mediaId: this.media.id,
|
mediaId: this.media.id,
|
||||||
tagIds: tags,
|
tagIds: tags,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
const overrideTags = appliedOverrideRules.find(
|
||||||
|
(rule) => rule.tags
|
||||||
|
)?.tags;
|
||||||
|
if (overrideTags) {
|
||||||
|
tags = [
|
||||||
|
...new Set([
|
||||||
|
...tags,
|
||||||
|
...overrideTags.split(',').map((tag) => Number(tag)),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
this.tags = tags;
|
||||||
|
logger.info(`Request has override tags from override rules`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
tagIds: tags,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sonarrSettings.tagRequests) {
|
if (sonarrSettings.tagRequests) {
|
||||||
@@ -1090,6 +1301,8 @@ export class MediaRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestRepository.save(this);
|
||||||
|
|
||||||
const sonarrSeriesOptions: AddSeriesOptions = {
|
const sonarrSeriesOptions: AddSeriesOptions = {
|
||||||
profileId: qualityProfile,
|
profileId: qualityProfile,
|
||||||
languageProfileId: languageProfile,
|
languageProfileId: languageProfile,
|
||||||
@@ -1126,8 +1339,6 @@ export class MediaRequest {
|
|||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
})
|
})
|
||||||
.catch(async () => {
|
.catch(async () => {
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
|
|
||||||
this.status = MediaRequestStatus.FAILED;
|
this.status = MediaRequestStatus.FAILED;
|
||||||
requestRepository.save(this);
|
requestRepository.save(this);
|
||||||
|
|
||||||
|
|||||||
52
server/entity/OverrideRule.ts
Normal file
52
server/entity/OverrideRule.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
class OverrideRule {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
public id: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
public radarrServiceId?: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
public sonarrServiceId?: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public users?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public genre?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public language?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public keywords?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
public profileId?: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public rootFolder?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public tags?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
public createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
public updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(init?: Partial<OverrideRule>) {
|
||||||
|
Object.assign(this, init);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OverrideRule;
|
||||||
@@ -19,8 +19,11 @@ import { getSettings } from '@server/lib/settings';
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import clearCookies from '@server/middleware/clearcookies';
|
import clearCookies from '@server/middleware/clearcookies';
|
||||||
import routes from '@server/routes';
|
import routes from '@server/routes';
|
||||||
|
import avatarproxy from '@server/routes/avatarproxy';
|
||||||
import imageproxy from '@server/routes/imageproxy';
|
import imageproxy from '@server/routes/imageproxy';
|
||||||
|
import { appDataPermissions } from '@server/utils/appDataVolume';
|
||||||
import { getAppVersion } from '@server/utils/appVersion';
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
|
import createCustomProxyAgent from '@server/utils/customProxyAgent';
|
||||||
import restartFlag from '@server/utils/restartFlag';
|
import restartFlag from '@server/utils/restartFlag';
|
||||||
import { getClientIp } from '@supercharge/request-ip';
|
import { getClientIp } from '@supercharge/request-ip';
|
||||||
import { TypeormStore } from 'connect-typeorm/out';
|
import { TypeormStore } from 'connect-typeorm/out';
|
||||||
@@ -32,10 +35,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()}`);
|
||||||
@@ -43,6 +53,12 @@ const dev = process.env.NODE_ENV !== 'production';
|
|||||||
const app = next({ dev });
|
const app = next({ dev });
|
||||||
const handle = app.getRequestHandler();
|
const handle = app.getRequestHandler();
|
||||||
|
|
||||||
|
if (!appDataPermissions()) {
|
||||||
|
logger.error(
|
||||||
|
'Something went wrong while checking config folder! Please ensure the config folder is set up properly.\nhttps://docs.jellyseerr.dev/getting-started'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
app
|
app
|
||||||
.prepare()
|
.prepare()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -56,9 +72,14 @@ app
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load Settings
|
// Load Settings
|
||||||
const settings = getSettings().load();
|
const settings = await getSettings().load();
|
||||||
restartFlag.initializeSettings(settings.main);
|
restartFlag.initializeSettings(settings.main);
|
||||||
|
|
||||||
|
// Register HTTP proxy
|
||||||
|
if (settings.main.proxy.enabled) {
|
||||||
|
await createCustomProxyAgent(settings.main.proxy);
|
||||||
|
}
|
||||||
|
|
||||||
// Migrate library types
|
// Migrate library types
|
||||||
if (
|
if (
|
||||||
settings.plex.libraries.length > 1 &&
|
settings.plex.libraries.length > 1 &&
|
||||||
@@ -167,7 +188,7 @@ app
|
|||||||
},
|
},
|
||||||
store: new TypeormStore({
|
store: new TypeormStore({
|
||||||
cleanupLimit: 2,
|
cleanupLimit: 2,
|
||||||
ttl: 1000 * 60 * 60 * 24 * 30,
|
ttl: 60 * 60 * 24 * 30,
|
||||||
}).connect(sessionRespository) as Store,
|
}).connect(sessionRespository) as Store,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -195,6 +216,7 @@ app
|
|||||||
|
|
||||||
// Do not set cookies so CDNs can cache them
|
// Do not set cookies so CDNs can cache them
|
||||||
server.use('/imageproxy', clearCookies, imageproxy);
|
server.use('/imageproxy', clearCookies, imageproxy);
|
||||||
|
server.use('/avatarproxy', clearCookies, avatarproxy);
|
||||||
|
|
||||||
server.get('*', (req, res) => handle(req, res));
|
server.get('*', (req, res) => handle(req, res));
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
14
server/interfaces/api/blacklistInterfaces.ts
Normal file
14
server/interfaces/api/blacklistInterfaces.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { User } from '@server/entity/User';
|
||||||
|
import type { PaginatedResponse } from '@server/interfaces/api/common';
|
||||||
|
|
||||||
|
export interface BlacklistItem {
|
||||||
|
tmdbId: number;
|
||||||
|
mediaType: 'movie' | 'tv';
|
||||||
|
title?: string;
|
||||||
|
createdAt?: Date;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlacklistResultsResponse extends PaginatedResponse {
|
||||||
|
results: BlacklistItem[];
|
||||||
|
}
|
||||||
@@ -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>>;
|
||||||
|
|||||||
3
server/interfaces/api/overrideRuleInterfaces.ts
Normal file
3
server/interfaces/api/overrideRuleInterfaces.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type OverrideRule from '@server/entity/OverrideRule';
|
||||||
|
|
||||||
|
export type OverrideRuleResultsResponse = OverrideRule[];
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export interface CacheItem {
|
|||||||
|
|
||||||
export interface CacheResponse {
|
export interface CacheResponse {
|
||||||
apiCaches: CacheItem[];
|
apiCaches: CacheItem[];
|
||||||
imageCache: Record<'tmdb', { size: number; imageCount: number }>;
|
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusResponse {
|
export interface StatusResponse {
|
||||||
|
|||||||
@@ -227,6 +227,9 @@ export const startJobs = (): void => {
|
|||||||
});
|
});
|
||||||
// Clean TMDB image cache
|
// Clean TMDB image cache
|
||||||
ImageProxy.clearCache('tmdb');
|
ImageProxy.clearCache('tmdb');
|
||||||
|
|
||||||
|
// Clean users avatar image cache
|
||||||
|
ImageProxy.clearCache('avatar');
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface DownloadingItem {
|
|||||||
timeLeft: string;
|
timeLeft: string;
|
||||||
estimatedCompletionTime: Date;
|
estimatedCompletionTime: Date;
|
||||||
title: string;
|
title: string;
|
||||||
|
downloadId: string;
|
||||||
episode?: EpisodeNumberResult;
|
episode?: EpisodeNumberResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +85,7 @@ class DownloadTracker {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await radarr.refreshMonitoredDownloads();
|
||||||
const queueItems = await radarr.getQueue();
|
const queueItems = await radarr.getQueue();
|
||||||
|
|
||||||
this.radarrServers[server.id] = queueItems.map((item) => ({
|
this.radarrServers[server.id] = queueItems.map((item) => ({
|
||||||
@@ -95,6 +97,7 @@ class DownloadTracker {
|
|||||||
status: item.status,
|
status: item.status,
|
||||||
timeLeft: item.timeleft,
|
timeLeft: item.timeleft,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
|
downloadId: item.downloadId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (queueItems.length > 0) {
|
if (queueItems.length > 0) {
|
||||||
@@ -160,6 +163,7 @@ class DownloadTracker {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await sonarr.refreshMonitoredDownloads();
|
||||||
const queueItems = await sonarr.getQueue();
|
const queueItems = await sonarr.getQueue();
|
||||||
|
|
||||||
this.sonarrServers[server.id] = queueItems.map((item) => ({
|
this.sonarrServers[server.id] = queueItems.map((item) => ({
|
||||||
@@ -172,6 +176,7 @@ class DownloadTracker {
|
|||||||
timeLeft: item.timeleft,
|
timeLeft: item.timeleft,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
episode: item.episode,
|
episode: item.episode,
|
||||||
|
downloadId: item.downloadId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (queueItems.length > 0) {
|
if (queueItems.length > 0) {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
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 mime from 'mime/lite';
|
||||||
import path, { join } from 'path';
|
import path, { join } from 'path';
|
||||||
|
|
||||||
type ImageResponse = {
|
type ImageResponse = {
|
||||||
@@ -11,7 +12,7 @@ type ImageResponse = {
|
|||||||
curRevalidate: number;
|
curRevalidate: number;
|
||||||
isStale: boolean;
|
isStale: boolean;
|
||||||
etag: string;
|
etag: string;
|
||||||
extension: string;
|
extension: string | null;
|
||||||
cacheKey: string;
|
cacheKey: string;
|
||||||
cacheMiss: boolean;
|
cacheMiss: boolean;
|
||||||
};
|
};
|
||||||
@@ -27,29 +28,45 @@ class ImageProxy {
|
|||||||
let deletedImages = 0;
|
let deletedImages = 0;
|
||||||
const cacheDirectory = path.join(baseCacheDirectory, key);
|
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||||
|
|
||||||
const files = await promises.readdir(cacheDirectory);
|
try {
|
||||||
|
const files = await promises.readdir(cacheDirectory);
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const filePath = path.join(cacheDirectory, file);
|
const filePath = path.join(cacheDirectory, file);
|
||||||
const stat = await promises.lstat(filePath);
|
const stat = await promises.lstat(filePath);
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
const imageFiles = await promises.readdir(filePath);
|
const imageFiles = await promises.readdir(filePath);
|
||||||
|
|
||||||
for (const imageFile of imageFiles) {
|
for (const imageFile of imageFiles) {
|
||||||
const [, expireAtSt] = imageFile.split('.');
|
const [, expireAtSt] = imageFile.split('.');
|
||||||
const expireAt = Number(expireAtSt);
|
const expireAt = Number(expireAtSt);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (now > expireAt) {
|
if (now > expireAt) {
|
||||||
await promises.rm(path.join(filePath, imageFile));
|
await promises.rm(path.join(filePath), {
|
||||||
deletedImages += 1;
|
recursive: true,
|
||||||
|
});
|
||||||
|
deletedImages += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'ENOENT') {
|
||||||
|
logger.error('Directory not found', {
|
||||||
|
label: 'Image Cache',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.error('Failed to read directory', {
|
||||||
|
label: 'Image Cache',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Cleared ${deletedImages} stale image(s) from cache`, {
|
logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, {
|
||||||
label: 'Image Cache',
|
label: 'Image Cache',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -69,59 +86,84 @@ class ImageProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async getDirectorySize(dir: string): Promise<number> {
|
private static async getDirectorySize(dir: string): Promise<number> {
|
||||||
const files = await promises.readdir(dir, {
|
try {
|
||||||
withFileTypes: true,
|
const files = await promises.readdir(dir, {
|
||||||
});
|
withFileTypes: true,
|
||||||
|
});
|
||||||
|
|
||||||
const paths = files.map(async (file) => {
|
const paths = files.map(async (file) => {
|
||||||
const path = join(dir, file.name);
|
const path = join(dir, file.name);
|
||||||
|
|
||||||
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
|
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
|
||||||
|
|
||||||
if (file.isFile()) {
|
if (file.isFile()) {
|
||||||
const { size } = await promises.stat(path);
|
const { size } = await promises.stat(path);
|
||||||
|
|
||||||
return size;
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (await Promise.all(paths))
|
||||||
|
.flat(Infinity)
|
||||||
|
.reduce((i, size) => i + size, 0);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'ENOENT') {
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
});
|
|
||||||
|
|
||||||
return (await Promise.all(paths))
|
|
||||||
.flat(Infinity)
|
|
||||||
.reduce((i, size) => i + size, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async getImageCount(dir: string) {
|
private static async getImageCount(dir: string) {
|
||||||
const files = await promises.readdir(dir);
|
try {
|
||||||
|
const files = await promises.readdir(dir);
|
||||||
|
|
||||||
return files.length;
|
return files.length;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'ENOENT') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private axios;
|
private fetch: typeof fetch;
|
||||||
private cacheVersion;
|
private cacheVersion;
|
||||||
private key;
|
private key;
|
||||||
|
private baseUrl;
|
||||||
|
private headers: HeadersInit | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
key: string,
|
key: string,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
options: {
|
options: {
|
||||||
cacheVersion?: number;
|
cacheVersion?: number;
|
||||||
rateLimitOptions?: rateLimitOptions;
|
rateLimitOptions?: RateLimitOptions;
|
||||||
|
headers?: HeadersInit;
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
this.headers = options.headers || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getImage(path: string): Promise<ImageResponse> {
|
public async getImage(
|
||||||
|
path: string,
|
||||||
|
fallbackPath?: string
|
||||||
|
): Promise<ImageResponse> {
|
||||||
const cacheKey = this.getCacheKey(path);
|
const cacheKey = this.getCacheKey(path);
|
||||||
|
|
||||||
const imageResponse = await this.get(cacheKey);
|
const imageResponse = await this.get(cacheKey);
|
||||||
@@ -130,7 +172,11 @@ class ImageProxy {
|
|||||||
const newImage = await this.set(path, cacheKey);
|
const newImage = await this.set(path, cacheKey);
|
||||||
|
|
||||||
if (!newImage) {
|
if (!newImage) {
|
||||||
throw new Error('Failed to load image');
|
if (fallbackPath) {
|
||||||
|
return await this.getImage(fallbackPath);
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to load image');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return newImage;
|
return newImage;
|
||||||
@@ -144,6 +190,27 @@ class ImageProxy {
|
|||||||
return imageResponse;
|
return imageResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async clearCachedImage(path: string) {
|
||||||
|
// find cacheKey
|
||||||
|
const cacheKey = this.getCacheKey(path);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||||
|
const files = await promises.readdir(directory);
|
||||||
|
|
||||||
|
await promises.rm(directory, { recursive: true });
|
||||||
|
|
||||||
|
logger.info(`Cleared ${files[0]} from cache 'avatar'`, {
|
||||||
|
label: 'Image Cache',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to clear cached image', {
|
||||||
|
label: 'Image Cache',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async get(cacheKey: string): Promise<ImageResponse | null> {
|
private async get(cacheKey: string): Promise<ImageResponse | null> {
|
||||||
try {
|
try {
|
||||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||||
@@ -182,17 +249,34 @@ 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.length > 0
|
||||||
|
? this.baseUrl.endsWith('/')
|
||||||
|
? ''
|
||||||
|
: '/'
|
||||||
|
: '') +
|
||||||
|
(path.startsWith('/') ? path.slice(1) : path);
|
||||||
|
const response = await this.fetch(href, {
|
||||||
|
headers: this.headers || undefined,
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
const buffer = Buffer.from(response.data, 'binary');
|
const extension = mime.getExtension(
|
||||||
const extension = path.split('.').pop() ?? '';
|
response.headers.get('content-type') ?? ''
|
||||||
const maxAge = Number(
|
|
||||||
(response.headers['cache-control'] ?? '0').split('=')[1]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let maxAge = Number(
|
||||||
|
(response.headers.get('cache-control') ?? '0').split('=')[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!maxAge) maxAge = 86400;
|
||||||
const expireAt = Date.now() + maxAge * 1000;
|
const expireAt = Date.now() + maxAge * 1000;
|
||||||
const etag = (response.headers.etag ?? '').replace(/"/g, '');
|
const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
|
||||||
|
|
||||||
await this.writeToCacheDir(
|
await this.writeToCacheDir(
|
||||||
directory,
|
directory,
|
||||||
@@ -226,7 +310,7 @@ class ImageProxy {
|
|||||||
|
|
||||||
private async writeToCacheDir(
|
private async writeToCacheDir(
|
||||||
dir: string,
|
dir: string,
|
||||||
extension: string,
|
extension: string | null,
|
||||||
maxAge: number,
|
maxAge: number,
|
||||||
expireAt: number,
|
expireAt: number,
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
|
|||||||
@@ -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,43 @@ class DiscordAgent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await axios.post(settings.options.webhookUrl, {
|
if (settings.options.webhookRoleId) {
|
||||||
username: settings.options.botUsername
|
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
|
||||||
? settings.options.botUsername
|
}
|
||||||
: getSettings().main.applicationTitle,
|
|
||||||
avatar_url: settings.options.botAvatarUrl,
|
const response = await fetch(settings.options.webhookUrl, {
|
||||||
embeds: [this.buildEmbed(type, payload)],
|
method: 'POST',
|
||||||
content: userMentions.join(' '),
|
headers: {
|
||||||
} as DiscordWebhookPayload);
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: settings.options.botUsername
|
||||||
|
? settings.options.botUsername
|
||||||
|
: getSettings().main.applicationTitle,
|
||||||
|
avatar_url: settings.options.botAvatarUrl,
|
||||||
|
embeds: [this.buildEmbed(type, payload)],
|
||||||
|
content: userMentions.join(' '),
|
||||||
|
} as DiscordWebhookPayload),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
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;
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export enum Permission {
|
|||||||
AUTO_REQUEST_TV = 33554432,
|
AUTO_REQUEST_TV = 33554432,
|
||||||
RECENT_VIEW = 67108864,
|
RECENT_VIEW = 67108864,
|
||||||
WATCHLIST_VIEW = 134217728,
|
WATCHLIST_VIEW = 134217728,
|
||||||
|
MANAGE_BLACKLIST = 268435456,
|
||||||
|
VIEW_BLACKLIST = 1073741824,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PermissionCheckOptions {
|
export interface PermissionCheckOptions {
|
||||||
|
|||||||
@@ -567,7 +567,10 @@ class JellyfinScanner {
|
|||||||
public async run(): Promise<void> {
|
public async run(): Promise<void> {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
if (settings.main.mediaServerType != MediaServerType.JELLYFIN) {
|
if (
|
||||||
|
settings.main.mediaServerType != MediaServerType.JELLYFIN &&
|
||||||
|
settings.main.mediaServerType != MediaServerType.EMBY
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,12 +585,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 +593,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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class PlexScanner
|
|||||||
});
|
});
|
||||||
|
|
||||||
settings.plex.libraries = newLibraries;
|
settings.plex.libraries = newLibraries;
|
||||||
settings.save();
|
await settings.save();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const library of this.libraries) {
|
for (const library of this.libraries) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { MediaServerType } from '@server/constants/server';
|
|||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
import { runMigrations } from '@server/lib/settings/migrator';
|
import { runMigrations } from '@server/lib/settings/migrator';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import fs from 'fs';
|
import fs from 'fs/promises';
|
||||||
import { merge } from 'lodash';
|
import { merge } from 'lodash';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
@@ -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;
|
||||||
@@ -75,6 +76,7 @@ export interface DVRSettings {
|
|||||||
syncEnabled: boolean;
|
syncEnabled: boolean;
|
||||||
preventSearch: boolean;
|
preventSearch: boolean;
|
||||||
tagRequests: boolean;
|
tagRequests: boolean;
|
||||||
|
overrideRule: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RadarrSettings extends DVRSettings {
|
export interface RadarrSettings extends DVRSettings {
|
||||||
@@ -98,6 +100,17 @@ interface Quota {
|
|||||||
quotaDays?: number;
|
quotaDays?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProxySettings {
|
||||||
|
enabled: boolean;
|
||||||
|
hostname: string;
|
||||||
|
port: number;
|
||||||
|
useSsl: boolean;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
bypassFilter: string;
|
||||||
|
bypassLocalAddresses: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MainSettings {
|
export interface MainSettings {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
applicationTitle: string;
|
applicationTitle: string;
|
||||||
@@ -118,6 +131,7 @@ export interface MainSettings {
|
|||||||
mediaServerType: number;
|
mediaServerType: number;
|
||||||
partialRequestsEnabled: boolean;
|
partialRequestsEnabled: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
|
proxy: ProxySettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublicSettings {
|
interface PublicSettings {
|
||||||
@@ -157,6 +171,7 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig {
|
|||||||
botUsername?: string;
|
botUsername?: string;
|
||||||
botAvatarUrl?: string;
|
botAvatarUrl?: string;
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
|
webhookRoleId?: string;
|
||||||
enableMentions: boolean;
|
enableMentions: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -324,6 +339,16 @@ class Settings {
|
|||||||
mediaServerType: MediaServerType.NOT_CONFIGURED,
|
mediaServerType: MediaServerType.NOT_CONFIGURED,
|
||||||
partialRequestsEnabled: true,
|
partialRequestsEnabled: true,
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
|
proxy: {
|
||||||
|
enabled: false,
|
||||||
|
hostname: '',
|
||||||
|
port: 8080,
|
||||||
|
useSsl: false,
|
||||||
|
user: '',
|
||||||
|
password: '',
|
||||||
|
bypassFilter: '',
|
||||||
|
bypassLocalAddresses: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plex: {
|
plex: {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -342,6 +367,7 @@ class Settings {
|
|||||||
jellyfinForgotPasswordUrl: '',
|
jellyfinForgotPasswordUrl: '',
|
||||||
libraries: [],
|
libraries: [],
|
||||||
serverId: '',
|
serverId: '',
|
||||||
|
apiKey: '',
|
||||||
},
|
},
|
||||||
tautulli: {},
|
tautulli: {},
|
||||||
radarr: [],
|
radarr: [],
|
||||||
@@ -370,6 +396,7 @@ class Settings {
|
|||||||
types: 0,
|
types: 0,
|
||||||
options: {
|
options: {
|
||||||
webhookUrl: '',
|
webhookUrl: '',
|
||||||
|
webhookRoleId: '',
|
||||||
enableMentions: true,
|
enableMentions: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -477,10 +504,6 @@ class Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get main(): MainSettings {
|
get main(): MainSettings {
|
||||||
if (!this.data.main.apiKey) {
|
|
||||||
this.data.main.apiKey = this.generateApiKey();
|
|
||||||
this.save();
|
|
||||||
}
|
|
||||||
return this.data.main;
|
return this.data.main;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,42 +605,28 @@ class Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get clientId(): string {
|
get clientId(): string {
|
||||||
if (!this.data.clientId) {
|
|
||||||
this.data.clientId = randomUUID();
|
|
||||||
this.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.data.clientId;
|
return this.data.clientId;
|
||||||
}
|
}
|
||||||
|
|
||||||
get vapidPublic(): string {
|
get vapidPublic(): string {
|
||||||
this.generateVapidKeys();
|
|
||||||
|
|
||||||
return this.data.vapidPublic;
|
return this.data.vapidPublic;
|
||||||
}
|
}
|
||||||
|
|
||||||
get vapidPrivate(): string {
|
get vapidPrivate(): string {
|
||||||
this.generateVapidKeys();
|
|
||||||
|
|
||||||
return this.data.vapidPrivate;
|
return this.data.vapidPrivate;
|
||||||
}
|
}
|
||||||
|
|
||||||
public regenerateApiKey(): MainSettings {
|
public async regenerateApiKey(): Promise<MainSettings> {
|
||||||
this.main.apiKey = this.generateApiKey();
|
this.main.apiKey = this.generateApiKey();
|
||||||
this.save();
|
await this.save();
|
||||||
return this.main;
|
return this.main;
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateApiKey(): string {
|
private generateApiKey(): string {
|
||||||
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
|
if (process.env.API_KEY) {
|
||||||
}
|
return process.env.API_KEY;
|
||||||
|
} else {
|
||||||
private generateVapidKeys(force = false): void {
|
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
|
||||||
if (!this.data.vapidPublic || !this.data.vapidPrivate || force) {
|
|
||||||
const vapidKeys = webpush.generateVAPIDKeys();
|
|
||||||
this.data.vapidPrivate = vapidKeys.privateKey;
|
|
||||||
this.data.vapidPublic = vapidKeys.publicKey;
|
|
||||||
this.save();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,30 +638,57 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(SETTINGS_PATH)) {
|
let data;
|
||||||
this.save();
|
try {
|
||||||
|
data = await fs.readFile(SETTINGS_PATH, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
await this.save();
|
||||||
}
|
}
|
||||||
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
const parsedJson = JSON.parse(data);
|
const parsedJson = JSON.parse(data);
|
||||||
this.data = runMigrations(parsedJson);
|
const migratedData = await runMigrations(parsedJson, SETTINGS_PATH);
|
||||||
|
this.data = merge(this.data, migratedData);
|
||||||
this.data = merge(this.data, parsedJson);
|
|
||||||
|
|
||||||
this.save();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generate keys and ids if it's missing
|
||||||
|
let change = false;
|
||||||
|
if (!this.data.main.apiKey) {
|
||||||
|
this.data.main.apiKey = this.generateApiKey();
|
||||||
|
change = true;
|
||||||
|
} else if (process.env.API_KEY) {
|
||||||
|
if (this.main.apiKey != process.env.API_KEY) {
|
||||||
|
this.main.apiKey = process.env.API_KEY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!this.data.clientId) {
|
||||||
|
this.data.clientId = randomUUID();
|
||||||
|
change = true;
|
||||||
|
}
|
||||||
|
if (!this.data.vapidPublic || !this.data.vapidPrivate) {
|
||||||
|
const vapidKeys = webpush.generateVAPIDKeys();
|
||||||
|
this.data.vapidPrivate = vapidKeys.privateKey;
|
||||||
|
this.data.vapidPublic = vapidKeys.publicKey;
|
||||||
|
change = true;
|
||||||
|
}
|
||||||
|
if (change) {
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public save(): void {
|
public async save(): Promise<void> {
|
||||||
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(this.data, undefined, ' '));
|
await fs.writeFile(
|
||||||
|
SETTINGS_PATH,
|
||||||
|
JSON.stringify(this.data, undefined, ' ')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import type { AllSettings } from '@server/lib/settings';
|
import type { AllSettings } from '@server/lib/settings';
|
||||||
|
|
||||||
const migrateHostname = (settings: any): AllSettings => {
|
const migrateHostname = (settings: any): AllSettings => {
|
||||||
const oldJellyfinSettings = settings.jellyfin;
|
if (settings.jellyfin?.hostname) {
|
||||||
if (oldJellyfinSettings && oldJellyfinSettings.hostname) {
|
const { hostname } = settings.jellyfin;
|
||||||
const { hostname } = oldJellyfinSettings;
|
|
||||||
const protocolMatch = hostname.match(/^(https?):\/\//i);
|
const protocolMatch = hostname.match(/^(https?):\/\//i);
|
||||||
const useSsl = protocolMatch && protocolMatch[1].toLowerCase() === 'https';
|
const useSsl = protocolMatch && protocolMatch[1].toLowerCase() === 'https';
|
||||||
const remainingUrl = hostname.replace(/^(https?):\/\//i, '');
|
const remainingUrl = hostname.replace(/^(https?):\/\//i, '');
|
||||||
const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/);
|
const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/);
|
||||||
|
|
||||||
delete oldJellyfinSettings.hostname;
|
delete settings.jellyfin.hostname;
|
||||||
if (urlMatch) {
|
if (urlMatch) {
|
||||||
const [, ip, , port, urlBase] = urlMatch;
|
const [, ip, , port, urlBase] = urlMatch;
|
||||||
settings.jellyfin = {
|
settings.jellyfin = {
|
||||||
@@ -21,9 +20,7 @@ const migrateHostname = (settings: any): AllSettings => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (settings.jellyfin && settings.jellyfin.hostname) {
|
|
||||||
delete settings.jellyfin.hostname;
|
|
||||||
}
|
|
||||||
return settings;
|
return settings;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
42
server/lib/settings/migrations/0002_migrate_apitokens.ts
Normal file
42
server/lib/settings/migrations/0002_migrate_apitokens.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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 ?? '');
|
||||||
|
try {
|
||||||
|
const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
|
||||||
|
settings.jellyfin.apiKey = apiKey;
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
"Failed to create Jellyfin API token from admin account. Please check your network configuration or edit your settings.json by adding an 'apiKey' field inside of the 'jellyfin' section to fix this issue."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default migrateApiTokens;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import type { AllSettings } from '@server/lib/settings';
|
||||||
|
|
||||||
|
const migrateHostname = (settings: any): AllSettings => {
|
||||||
|
const oldMediaServerType = settings.main.mediaServerType;
|
||||||
|
if (
|
||||||
|
oldMediaServerType === MediaServerType.JELLYFIN &&
|
||||||
|
process.env.JELLYFIN_TYPE === 'emby'
|
||||||
|
) {
|
||||||
|
settings.main.mediaServerType = MediaServerType.EMBY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default migrateHostname;
|
||||||
@@ -1,20 +1,100 @@
|
|||||||
import type { AllSettings } from '@server/lib/settings';
|
import type { AllSettings } from '@server/lib/settings';
|
||||||
import fs from 'fs';
|
import logger from '@server/logger';
|
||||||
|
import fs from 'fs/promises';
|
||||||
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 (
|
||||||
const migrations = fs
|
settings: AllSettings,
|
||||||
.readdirSync(migrationsDir)
|
SETTINGS_PATH: string
|
||||||
.filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
|
): Promise<AllSettings> => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
||||||
.map((file) => require(path.join(migrationsDir, file)).default);
|
|
||||||
|
|
||||||
let migrated = settings;
|
let migrated = settings;
|
||||||
|
|
||||||
for (const migration of migrations) {
|
try {
|
||||||
migrated = migration(migrated);
|
// we read old backup and create a backup of currents settings
|
||||||
|
const BACKUP_PATH = SETTINGS_PATH.replace('.json', '.old.json');
|
||||||
|
let oldBackup: string | null = null;
|
||||||
|
try {
|
||||||
|
oldBackup = await fs.readFile(BACKUP_PATH, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
await fs.writeFile(BACKUP_PATH, JSON.stringify(settings, undefined, ' '));
|
||||||
|
|
||||||
|
const migrations = (await fs.readdir(migrationsDir)).filter(
|
||||||
|
(file) => file.endsWith('.js') || file.endsWith('.ts')
|
||||||
|
);
|
||||||
|
|
||||||
|
const settingsBefore = JSON.stringify(migrated);
|
||||||
|
|
||||||
|
for (const migration of migrations) {
|
||||||
|
try {
|
||||||
|
logger.debug(`Checking migration '${migration}'...`, {
|
||||||
|
label: 'Settings Migrator',
|
||||||
|
});
|
||||||
|
const { default: migrationFn } = await import(
|
||||||
|
path.join(migrationsDir, migration)
|
||||||
|
);
|
||||||
|
const newSettings = await migrationFn(structuredClone(migrated));
|
||||||
|
if (JSON.stringify(migrated) !== JSON.stringify(newSettings)) {
|
||||||
|
logger.debug(`Migration '${migration}' has been applied.`, {
|
||||||
|
label: 'Settings Migrator',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
migrated = newSettings;
|
||||||
|
} catch (e) {
|
||||||
|
// we stop jellyseerr if the migration failed
|
||||||
|
logger.error(
|
||||||
|
`Error while running migration '${migration}': ${e.message}`,
|
||||||
|
{
|
||||||
|
label: 'Settings Migrator',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
logger.error(
|
||||||
|
'A common cause for this error is a permission issue with your configuration folder, a network issue or a corrupted database.',
|
||||||
|
{
|
||||||
|
label: 'Settings Migrator',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
process.exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsAfter = JSON.stringify(migrated);
|
||||||
|
|
||||||
|
if (settingsBefore !== settingsAfter) {
|
||||||
|
// a migration occured
|
||||||
|
// we check that the new config will be saved
|
||||||
|
await fs.writeFile(
|
||||||
|
SETTINGS_PATH,
|
||||||
|
JSON.stringify(migrated, undefined, ' ')
|
||||||
|
);
|
||||||
|
const fileSaved = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
|
||||||
|
if (JSON.stringify(fileSaved) !== settingsAfter) {
|
||||||
|
// something went wrong while saving file
|
||||||
|
throw new Error('Unable to save settings after migration.');
|
||||||
|
}
|
||||||
|
} else if (oldBackup) {
|
||||||
|
// no migration occured
|
||||||
|
// we save the old backup (to avoid settings.json and settings.old.json being the same)
|
||||||
|
await fs.writeFile(BACKUP_PATH, oldBackup.toString());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// we stop jellyseerr if the migration failed
|
||||||
|
logger.error(
|
||||||
|
`Something went wrong while running settings migrations: ${e.message}`,
|
||||||
|
{
|
||||||
|
label: 'Settings Migrator',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
logger.error(
|
||||||
|
'A common cause for this issue is a permission error of your configuration folder.',
|
||||||
|
{
|
||||||
|
label: 'Settings Migrator',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
process.exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
return migrated;
|
return migrated;
|
||||||
|
|||||||
20
server/migration/1699901142442-AddBlacklist.ts
Normal file
20
server/migration/1699901142442-AddBlacklist.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddBlacklist1699901142442 implements MigrationInterface {
|
||||||
|
name = 'AddBlacklist1699901142442';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')),"userId" integer, "mediaId" integer,CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId", "userId"))`
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
server/migration/1731963944025-AddOverrideRules.ts
Normal file
15
server/migration/1731963944025-AddOverrideRules.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddOverrideRules1731963944025 implements MigrationInterface {
|
||||||
|
name = 'AddOverrideRules1731963944025';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE "override_rule"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import JellyfinAPI from '@server/api/jellyfin';
|
import JellyfinAPI from '@server/api/jellyfin';
|
||||||
import PlexTvAPI from '@server/api/plextv';
|
import PlexTvAPI from '@server/api/plextv';
|
||||||
import { ApiErrorCode } from '@server/constants/error';
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType, ServerType } from '@server/constants/server';
|
||||||
import { UserType } from '@server/constants/user';
|
import { UserType } from '@server/constants/user';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
@@ -14,7 +14,6 @@ import { ApiError } from '@server/types/error';
|
|||||||
import { getHostname } from '@server/utils/getHostname';
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import * as EmailValidator from 'email-validator';
|
import * as EmailValidator from 'email-validator';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import gravatarUrl from 'gravatar-url';
|
|
||||||
import net from 'net';
|
import net from 'net';
|
||||||
|
|
||||||
const authRoutes = Router();
|
const authRoutes = Router();
|
||||||
@@ -88,7 +87,7 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
settings.main.mediaServerType = MediaServerType.PLEX;
|
settings.main.mediaServerType = MediaServerType.PLEX;
|
||||||
settings.save();
|
await settings.save();
|
||||||
startJobs();
|
startJobs();
|
||||||
|
|
||||||
await userRepository.save(user);
|
await userRepository.save(user);
|
||||||
@@ -227,15 +226,20 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
urlBase?: string;
|
urlBase?: string;
|
||||||
useSsl?: boolean;
|
useSsl?: boolean;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
serverType?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured
|
//Make sure jellyfin login is enabled, but only if jellyfin && Emby is not already configured
|
||||||
if (
|
if (
|
||||||
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||||
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
|
settings.main.mediaServerType !== MediaServerType.EMBY &&
|
||||||
|
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
|
||||||
|
settings.jellyfin.ip !== ''
|
||||||
) {
|
) {
|
||||||
return res.status(500).json({ error: 'Jellyfin login is disabled' });
|
return res.status(500).json({ error: 'Jellyfin login is disabled' });
|
||||||
} else if (!body.username) {
|
}
|
||||||
|
|
||||||
|
if (!body.username) {
|
||||||
return res.status(500).json({ error: 'You must provide an username' });
|
return res.status(500).json({ error: 'You must provide an username' });
|
||||||
} else if (settings.jellyfin.ip !== '' && body.hostname) {
|
} else if (settings.jellyfin.ip !== '' && body.hostname) {
|
||||||
return res
|
return res
|
||||||
@@ -256,8 +260,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
urlBase: body.urlBase,
|
urlBase: body.urlBase,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { externalHostname } = getSettings().jellyfin;
|
|
||||||
|
|
||||||
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
|
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
|
||||||
let user = await userRepository.findOne({
|
let user = await userRepository.findOne({
|
||||||
where: { jellyfinUsername: body.username },
|
where: { jellyfinUsername: body.username },
|
||||||
@@ -273,11 +275,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// First we need to attempt to log the user in to jellyfin
|
// First we need to attempt to log the user in to jellyfin
|
||||||
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
|
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
|
||||||
const jellyfinHost =
|
|
||||||
externalHostname && externalHostname.length > 0
|
|
||||||
? externalHostname
|
|
||||||
: hostname;
|
|
||||||
|
|
||||||
const ip = req.ip;
|
const ip = req.ip;
|
||||||
let clientIp;
|
let clientIp;
|
||||||
@@ -301,36 +299,93 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
where: { jellyfinUserId: account.User.Id },
|
where: { jellyfinUserId: account.User.Id },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user && !(await userRepository.count())) {
|
const missingAdminUser = !user && !(await userRepository.count());
|
||||||
|
if (
|
||||||
|
missingAdminUser ||
|
||||||
|
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED
|
||||||
|
) {
|
||||||
// Check if user is admin on jellyfin
|
// Check if user is admin on jellyfin
|
||||||
if (account.User.Policy.IsAdministrator === false) {
|
if (account.User.Policy.IsAdministrator === false) {
|
||||||
throw new ApiError(403, ApiErrorCode.NotAdmin);
|
throw new ApiError(403, ApiErrorCode.NotAdmin);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
if (
|
||||||
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr',
|
body.serverType !== MediaServerType.JELLYFIN &&
|
||||||
{
|
body.serverType !== MediaServerType.EMBY
|
||||||
label: 'API',
|
) {
|
||||||
ip: req.ip,
|
throw new Error('select_server_type');
|
||||||
jellyfinUsername: account.User.Name,
|
}
|
||||||
}
|
settings.main.mediaServerType = body.serverType;
|
||||||
);
|
|
||||||
|
|
||||||
// User doesn't exist, and there are no users in the database, we'll create the user
|
if (missingAdminUser) {
|
||||||
// with admin permission
|
logger.info(
|
||||||
settings.main.mediaServerType = MediaServerType.JELLYFIN;
|
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Jellyseerr',
|
||||||
user = new User({
|
{
|
||||||
email: body.email,
|
label: 'API',
|
||||||
jellyfinUsername: account.User.Name,
|
ip: req.ip,
|
||||||
jellyfinUserId: account.User.Id,
|
jellyfinUsername: account.User.Name,
|
||||||
jellyfinDeviceId: deviceId,
|
}
|
||||||
jellyfinAuthToken: account.AccessToken,
|
);
|
||||||
permissions: Permission.ADMIN,
|
|
||||||
avatar: account.User.PrimaryImageTag
|
// User doesn't exist, and there are no users in the database, we'll create the user
|
||||||
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
// with admin permissions
|
||||||
: gravatarUrl(body.email ?? '', { default: 'mm', size: 200 }),
|
|
||||||
userType: UserType.JELLYFIN,
|
user = new User({
|
||||||
});
|
id: 1,
|
||||||
|
email: body.email || account.User.Name,
|
||||||
|
jellyfinUsername: account.User.Name,
|
||||||
|
jellyfinUserId: account.User.Id,
|
||||||
|
jellyfinDeviceId: deviceId,
|
||||||
|
jellyfinAuthToken: account.AccessToken,
|
||||||
|
permissions: Permission.ADMIN,
|
||||||
|
avatar: `/avatarproxy/${account.User.Id}`,
|
||||||
|
userType:
|
||||||
|
body.serverType === MediaServerType.JELLYFIN
|
||||||
|
? UserType.JELLYFIN
|
||||||
|
: UserType.EMBY,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userRepository.save(user);
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
'Sign-in attempt from Jellyfin user with access to the media server; editing admin user for Jellyseerr',
|
||||||
|
{
|
||||||
|
label: 'API',
|
||||||
|
ip: req.ip,
|
||||||
|
jellyfinUsername: account.User.Name,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// User alread exist but settings.json is not configured, we'll edit the admin user
|
||||||
|
|
||||||
|
user = await userRepository.findOne({
|
||||||
|
where: { id: 1 },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('Unable to find admin user to edit');
|
||||||
|
}
|
||||||
|
user.email = body.email || account.User.Name;
|
||||||
|
user.jellyfinUsername = account.User.Name;
|
||||||
|
user.jellyfinUserId = account.User.Id;
|
||||||
|
user.jellyfinDeviceId = deviceId;
|
||||||
|
user.jellyfinAuthToken = account.AccessToken;
|
||||||
|
user.permissions = Permission.ADMIN;
|
||||||
|
user.avatar = `/avatarproxy/${account.User.Id}`;
|
||||||
|
user.userType =
|
||||||
|
body.serverType === MediaServerType.JELLYFIN
|
||||||
|
? UserType.JELLYFIN
|
||||||
|
: UserType.EMBY;
|
||||||
|
|
||||||
|
await userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
@@ -340,22 +395,21 @@ 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.save();
|
settings.jellyfin.apiKey = apiKey;
|
||||||
|
await settings.save();
|
||||||
startJobs();
|
startJobs();
|
||||||
|
|
||||||
await userRepository.save(user);
|
|
||||||
}
|
}
|
||||||
// User already exists, let's update their information
|
// User already exists, let's update their information
|
||||||
else if (account.User.Id === user?.jellyfinUserId) {
|
else if (account.User.Id === user?.jellyfinUserId) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Found matching ${
|
`Found matching ${
|
||||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||||
? 'Jellyfin'
|
? ServerType.JELLYFIN
|
||||||
: 'Emby'
|
: ServerType.EMBY
|
||||||
} user; updating user with ${
|
} user; updating user with ${
|
||||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||||
? 'Jellyfin'
|
? ServerType.JELLYFIN
|
||||||
: 'Emby'
|
: ServerType.EMBY
|
||||||
}`,
|
}`,
|
||||||
{
|
{
|
||||||
label: 'API',
|
label: 'API',
|
||||||
@@ -363,31 +417,13 @@ 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
|
user.avatar = `/avatarproxy/${account.User.Id}`;
|
||||||
if (user.jellyfinAuthToken !== account.AccessToken) {
|
|
||||||
user.jellyfinAuthToken = account.AccessToken;
|
|
||||||
}
|
|
||||||
// Update the users avatar with their jellyfin profile pic (incase it changed)
|
|
||||||
if (account.User.PrimaryImageTag) {
|
|
||||||
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
|
|
||||||
} else {
|
|
||||||
user.avatar = gravatarUrl(user.email, {
|
|
||||||
default: 'mm',
|
|
||||||
size: 200,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
user.jellyfinUsername = account.User.Name;
|
user.jellyfinUsername = account.User.Name;
|
||||||
|
|
||||||
if (user.username === account.User.Name) {
|
if (user.username === account.User.Name) {
|
||||||
user.username = '';
|
user.username = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: If JELLYFIN_TYPE is set to 'emby' then set mediaServerType to EMBY
|
|
||||||
// if (process.env.JELLYFIN_TYPE === 'emby') {
|
|
||||||
// settings.main.mediaServerType = MediaServerType.EMBY;
|
|
||||||
// settings.save();
|
|
||||||
// }
|
|
||||||
|
|
||||||
await userRepository.save(user);
|
await userRepository.save(user);
|
||||||
} else if (!settings.main.newPlexLogin) {
|
} else if (!settings.main.newPlexLogin) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -413,22 +449,19 @@ 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: `/avatarproxy/${account.User.Id}`,
|
||||||
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
userType:
|
||||||
: gravatarUrl(body.email, { default: 'mm', size: 200 }),
|
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||||
userType: UserType.JELLYFIN,
|
? UserType.JELLYFIN
|
||||||
|
: UserType.EMBY,
|
||||||
});
|
});
|
||||||
|
|
||||||
//initialize Jellyfin/Emby users with local login
|
//initialize Jellyfin/Emby users with local login
|
||||||
const passedExplicitPassword = body.password && body.password.length > 0;
|
const passedExplicitPassword = body.password && body.password.length > 0;
|
||||||
if (passedExplicitPassword) {
|
if (passedExplicitPassword) {
|
||||||
@@ -730,6 +763,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',
|
||||||
|
|||||||
86
server/routes/avatarproxy.ts
Normal file
86
server/routes/avatarproxy.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import ImageProxy from '@server/lib/imageproxy';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
|
import { Router } from 'express';
|
||||||
|
import gravatarUrl from 'gravatar-url';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
let _avatarImageProxy: ImageProxy | null = null;
|
||||||
|
async function initAvatarImageProxy() {
|
||||||
|
if (!_avatarImageProxy) {
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
const admin = await userRepository.findOne({
|
||||||
|
where: { id: 1 },
|
||||||
|
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
||||||
|
order: { id: 'ASC' },
|
||||||
|
});
|
||||||
|
const deviceId = admin?.jellyfinDeviceId;
|
||||||
|
const authToken = getSettings().jellyfin.apiKey;
|
||||||
|
_avatarImageProxy = new ImageProxy('avatar', '', {
|
||||||
|
headers: {
|
||||||
|
'X-Emby-Authorization': `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _avatarImageProxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/:jellyfinUserId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) {
|
||||||
|
const mediaServerType = getSettings().main.mediaServerType;
|
||||||
|
throw new Error(
|
||||||
|
`Provided URL is not ${
|
||||||
|
mediaServerType === MediaServerType.JELLYFIN
|
||||||
|
? 'a Jellyfin'
|
||||||
|
: 'an Emby'
|
||||||
|
} avatar.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarImageCache = await initAvatarImageProxy();
|
||||||
|
|
||||||
|
const user = await getRepository(User).findOne({
|
||||||
|
where: { jellyfinUserId: req.params.jellyfinUserId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const fallbackUrl = gravatarUrl(user?.email || 'none', {
|
||||||
|
default: 'mm',
|
||||||
|
size: 200,
|
||||||
|
});
|
||||||
|
const jellyfinAvatarUrl = `${getHostname()}/UserImage?UserId=${
|
||||||
|
req.params.jellyfinUserId
|
||||||
|
}`;
|
||||||
|
let imageData = await avatarImageCache.getImage(
|
||||||
|
jellyfinAvatarUrl,
|
||||||
|
fallbackUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
if (imageData.meta.extension === 'json') {
|
||||||
|
// this is a 404
|
||||||
|
imageData = await avatarImageCache.getImage(fallbackUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': `image/${imageData.meta.extension}`,
|
||||||
|
'Content-Length': imageData.imageBuffer.length,
|
||||||
|
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
|
||||||
|
'OS-Cache-Key': imageData.meta.cacheKey,
|
||||||
|
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.end(imageData.imageBuffer);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to proxy avatar image', {
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
171
server/routes/blacklist.ts
Normal file
171
server/routes/blacklist.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { MediaType } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import { Blacklist } from '@server/entity/Blacklist';
|
||||||
|
import Media from '@server/entity/Media';
|
||||||
|
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
|
||||||
|
import { Permission } from '@server/lib/permissions';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { EntityNotFoundError, QueryFailedError } from 'typeorm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const blacklistRoutes = Router();
|
||||||
|
|
||||||
|
export const blacklistAdd = z.object({
|
||||||
|
tmdbId: z.coerce.number(),
|
||||||
|
mediaType: z.nativeEnum(MediaType),
|
||||||
|
title: z.coerce.string().optional(),
|
||||||
|
user: z.coerce.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
blacklistRoutes.get(
|
||||||
|
'/',
|
||||||
|
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
|
||||||
|
type: 'or',
|
||||||
|
}),
|
||||||
|
async (req, res, next) => {
|
||||||
|
const pageSize = req.query.take ? Number(req.query.take) : 25;
|
||||||
|
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||||
|
const search = (req.query.search as string) ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let query = getRepository(Blacklist)
|
||||||
|
.createQueryBuilder('blacklist')
|
||||||
|
.leftJoinAndSelect('blacklist.user', 'user');
|
||||||
|
|
||||||
|
if (search.length > 0) {
|
||||||
|
query = query.where('blacklist.title like :title', {
|
||||||
|
title: `%${search}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [blacklistedItems, itemsCount] = await query
|
||||||
|
.orderBy('blacklist.createdAt', 'DESC')
|
||||||
|
.take(pageSize)
|
||||||
|
.skip(skip)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
pageInfo: {
|
||||||
|
pages: Math.ceil(itemsCount / pageSize),
|
||||||
|
pageSize,
|
||||||
|
results: itemsCount,
|
||||||
|
page: Math.ceil(skip / pageSize) + 1,
|
||||||
|
},
|
||||||
|
results: blacklistedItems,
|
||||||
|
} as BlacklistResultsResponse);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Something went wrong while retrieving blacklisted items', {
|
||||||
|
label: 'Blacklist',
|
||||||
|
errorMessage: error.message,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to retrieve blacklisted items.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
blacklistRoutes.get(
|
||||||
|
'/:id',
|
||||||
|
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
||||||
|
type: 'or',
|
||||||
|
}),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const blacklisteRepository = getRepository(Blacklist);
|
||||||
|
|
||||||
|
const blacklistItem = await blacklisteRepository.findOneOrFail({
|
||||||
|
where: { tmdbId: Number(req.params.id) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).send(blacklistItem);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof EntityNotFoundError) {
|
||||||
|
return next({
|
||||||
|
status: 401,
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next({ status: 500, message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
blacklistRoutes.post(
|
||||||
|
'/',
|
||||||
|
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
||||||
|
type: 'or',
|
||||||
|
}),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const values = blacklistAdd.parse(req.body);
|
||||||
|
|
||||||
|
await Blacklist.addToBlacklist({
|
||||||
|
blacklistRequest: values,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).send();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof QueryFailedError) {
|
||||||
|
switch (error.driverError.errno) {
|
||||||
|
case 19:
|
||||||
|
return next({ status: 412, message: 'Item already blacklisted' });
|
||||||
|
default:
|
||||||
|
logger.warn('Something wrong with data blacklist', {
|
||||||
|
tmdbId: req.body.tmdbId,
|
||||||
|
mediaType: req.body.mediaType,
|
||||||
|
label: 'Blacklist',
|
||||||
|
});
|
||||||
|
return next({ status: 409, message: 'Something wrong' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next({ status: 500, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
blacklistRoutes.delete(
|
||||||
|
'/:id',
|
||||||
|
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
||||||
|
type: 'or',
|
||||||
|
}),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const blacklisteRepository = getRepository(Blacklist);
|
||||||
|
|
||||||
|
const blacklistItem = await blacklisteRepository.findOneOrFail({
|
||||||
|
where: { tmdbId: Number(req.params.id) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await blacklisteRepository.remove(blacklistItem);
|
||||||
|
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
|
||||||
|
const mediaItem = await mediaRepository.findOneOrFail({
|
||||||
|
where: { tmdbId: Number(req.params.id) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await mediaRepository.remove(mediaItem);
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof EntityNotFoundError) {
|
||||||
|
return next({
|
||||||
|
status: 401,
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next({ status: 500, message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default blacklistRoutes;
|
||||||
@@ -71,6 +71,7 @@ const QueryFilterOptions = z.object({
|
|||||||
network: z.coerce.string().optional(),
|
network: z.coerce.string().optional(),
|
||||||
watchProviders: z.coerce.string().optional(),
|
watchProviders: z.coerce.string().optional(),
|
||||||
watchRegion: z.coerce.string().optional(),
|
watchRegion: z.coerce.string().optional(),
|
||||||
|
status: z.coerce.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||||
@@ -385,6 +386,7 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
|||||||
voteCountLte: query.voteCountLte,
|
voteCountLte: query.voteCountLte,
|
||||||
watchProviders: query.watchProviders,
|
watchProviders: query.watchProviders,
|
||||||
watchRegion: query.watchRegion,
|
watchRegion: query.watchRegion,
|
||||||
|
withStatus: query.status,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,14 +15,20 @@ import { checkUser, isAuthenticated } from '@server/middleware/auth';
|
|||||||
import { mapWatchProviderDetails } from '@server/models/common';
|
import { mapWatchProviderDetails } from '@server/models/common';
|
||||||
import { mapProductionCompany } from '@server/models/Movie';
|
import { mapProductionCompany } from '@server/models/Movie';
|
||||||
import { mapNetwork } from '@server/models/Tv';
|
import { mapNetwork } from '@server/models/Tv';
|
||||||
|
import overrideRuleRoutes from '@server/routes/overrideRule';
|
||||||
import settingsRoutes from '@server/routes/settings';
|
import settingsRoutes from '@server/routes/settings';
|
||||||
import watchlistRoutes from '@server/routes/watchlist';
|
import watchlistRoutes from '@server/routes/watchlist';
|
||||||
import { appDataPath, appDataStatus } from '@server/utils/appDataVolume';
|
import {
|
||||||
|
appDataPath,
|
||||||
|
appDataPermissions,
|
||||||
|
appDataStatus,
|
||||||
|
} from '@server/utils/appDataVolume';
|
||||||
import { getAppVersion, getCommitTag } from '@server/utils/appVersion';
|
import { getAppVersion, getCommitTag } from '@server/utils/appVersion';
|
||||||
import restartFlag from '@server/utils/restartFlag';
|
import restartFlag from '@server/utils/restartFlag';
|
||||||
import { isPerson } from '@server/utils/typeHelpers';
|
import { isPerson } from '@server/utils/typeHelpers';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import authRoutes from './auth';
|
import authRoutes from './auth';
|
||||||
|
import blacklistRoutes from './blacklist';
|
||||||
import collectionRoutes from './collection';
|
import collectionRoutes from './collection';
|
||||||
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
|
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
|
||||||
import issueRoutes from './issue';
|
import issueRoutes from './issue';
|
||||||
@@ -92,6 +98,7 @@ router.get('/status/appdata', (_req, res) => {
|
|||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
appData: appDataStatus(),
|
appData: appDataStatus(),
|
||||||
appDataPath: appDataPath(),
|
appDataPath: appDataPath(),
|
||||||
|
appDataPermissions: appDataPermissions(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,6 +151,7 @@ router.use('/search', isAuthenticated(), searchRoutes);
|
|||||||
router.use('/discover', isAuthenticated(), discoverRoutes);
|
router.use('/discover', isAuthenticated(), discoverRoutes);
|
||||||
router.use('/request', isAuthenticated(), requestRoutes);
|
router.use('/request', isAuthenticated(), requestRoutes);
|
||||||
router.use('/watchlist', isAuthenticated(), watchlistRoutes);
|
router.use('/watchlist', isAuthenticated(), watchlistRoutes);
|
||||||
|
router.use('/blacklist', isAuthenticated(), blacklistRoutes);
|
||||||
router.use('/movie', isAuthenticated(), movieRoutes);
|
router.use('/movie', isAuthenticated(), movieRoutes);
|
||||||
router.use('/tv', isAuthenticated(), tvRoutes);
|
router.use('/tv', isAuthenticated(), tvRoutes);
|
||||||
router.use('/media', isAuthenticated(), mediaRoutes);
|
router.use('/media', isAuthenticated(), mediaRoutes);
|
||||||
@@ -153,6 +161,11 @@ router.use('/service', isAuthenticated(), serviceRoutes);
|
|||||||
router.use('/issue', isAuthenticated(), issueRoutes);
|
router.use('/issue', isAuthenticated(), issueRoutes);
|
||||||
router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
|
router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
|
||||||
router.use('/auth', authRoutes);
|
router.use('/auth', authRoutes);
|
||||||
|
router.use(
|
||||||
|
'/overrideRule',
|
||||||
|
isAuthenticated(Permission.ADMIN),
|
||||||
|
overrideRuleRoutes
|
||||||
|
);
|
||||||
|
|
||||||
router.get('/regions', isAuthenticated(), async (req, res, next) => {
|
router.get('/regions', isAuthenticated(), async (req, res, next) => {
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
136
server/routes/overrideRule.ts
Normal file
136
server/routes/overrideRule.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import OverrideRule from '@server/entity/OverrideRule';
|
||||||
|
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
|
||||||
|
import { Permission } from '@server/lib/permissions';
|
||||||
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
const overrideRuleRoutes = Router();
|
||||||
|
|
||||||
|
overrideRuleRoutes.get(
|
||||||
|
'/',
|
||||||
|
isAuthenticated(Permission.ADMIN),
|
||||||
|
async (req, res, next) => {
|
||||||
|
const overrideRuleRepository = getRepository(OverrideRule);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rules = await overrideRuleRepository.find({});
|
||||||
|
|
||||||
|
return res.status(200).json(rules as OverrideRuleResultsResponse);
|
||||||
|
} catch (e) {
|
||||||
|
next({ status: 404, message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
overrideRuleRoutes.post<
|
||||||
|
Record<string, string>,
|
||||||
|
OverrideRule,
|
||||||
|
{
|
||||||
|
users?: string;
|
||||||
|
genre?: string;
|
||||||
|
language?: string;
|
||||||
|
keywords?: string;
|
||||||
|
profileId?: number;
|
||||||
|
rootFolder?: string;
|
||||||
|
tags?: string;
|
||||||
|
radarrServiceId?: number;
|
||||||
|
sonarrServiceId?: number;
|
||||||
|
}
|
||||||
|
>('/', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
|
||||||
|
const overrideRuleRepository = getRepository(OverrideRule);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rule = new OverrideRule({
|
||||||
|
users: req.body.users,
|
||||||
|
genre: req.body.genre,
|
||||||
|
language: req.body.language,
|
||||||
|
keywords: req.body.keywords,
|
||||||
|
profileId: req.body.profileId,
|
||||||
|
rootFolder: req.body.rootFolder,
|
||||||
|
tags: req.body.tags,
|
||||||
|
radarrServiceId: req.body.radarrServiceId,
|
||||||
|
sonarrServiceId: req.body.sonarrServiceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newRule = await overrideRuleRepository.save(rule);
|
||||||
|
|
||||||
|
return res.status(200).json(newRule);
|
||||||
|
} catch (e) {
|
||||||
|
next({ status: 404, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
overrideRuleRoutes.put<
|
||||||
|
{ ruleId: string },
|
||||||
|
OverrideRule,
|
||||||
|
{
|
||||||
|
users?: string;
|
||||||
|
genre?: string;
|
||||||
|
language?: string;
|
||||||
|
keywords?: string;
|
||||||
|
profileId?: number;
|
||||||
|
rootFolder?: string;
|
||||||
|
tags?: string;
|
||||||
|
radarrServiceId?: number;
|
||||||
|
sonarrServiceId?: number;
|
||||||
|
}
|
||||||
|
>('/:ruleId', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
|
||||||
|
const overrideRuleRepository = getRepository(OverrideRule);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rule = await overrideRuleRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: Number(req.params.ruleId),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rule) {
|
||||||
|
return next({ status: 404, message: 'Override Rule not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.users = req.body.users;
|
||||||
|
rule.genre = req.body.genre;
|
||||||
|
rule.language = req.body.language;
|
||||||
|
rule.keywords = req.body.keywords;
|
||||||
|
rule.profileId = req.body.profileId;
|
||||||
|
rule.rootFolder = req.body.rootFolder;
|
||||||
|
rule.tags = req.body.tags;
|
||||||
|
rule.radarrServiceId = req.body.radarrServiceId;
|
||||||
|
rule.sonarrServiceId = req.body.sonarrServiceId;
|
||||||
|
|
||||||
|
const newRule = await overrideRuleRepository.save(rule);
|
||||||
|
|
||||||
|
return res.status(200).json(newRule);
|
||||||
|
} catch (e) {
|
||||||
|
next({ status: 404, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
overrideRuleRoutes.delete<{ ruleId: string }, OverrideRule>(
|
||||||
|
'/:ruleId',
|
||||||
|
isAuthenticated(Permission.ADMIN),
|
||||||
|
async (req, res, next) => {
|
||||||
|
const overrideRuleRepository = getRepository(OverrideRule);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rule = await overrideRuleRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: Number(req.params.ruleId),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rule) {
|
||||||
|
return next({ status: 404, message: 'Override Rule not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await overrideRuleRepository.remove(rule);
|
||||||
|
|
||||||
|
return res.status(200).json(rule);
|
||||||
|
} catch (e) {
|
||||||
|
next({ status: 404, message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default overrideRuleRoutes;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
import {
|
import {
|
||||||
MediaRequestStatus,
|
MediaRequestStatus,
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
@@ -6,6 +8,7 @@ import {
|
|||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import {
|
import {
|
||||||
|
BlacklistedMediaError,
|
||||||
DuplicateMediaRequestError,
|
DuplicateMediaRequestError,
|
||||||
MediaRequest,
|
MediaRequest,
|
||||||
NoSeasonsAvailableError,
|
NoSeasonsAvailableError,
|
||||||
@@ -19,6 +22,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 +147,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 +210,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 });
|
||||||
@@ -184,6 +244,8 @@ requestRoutes.post<never, MediaRequest, MediaRequestBody>(
|
|||||||
return next({ status: 409, message: error.message });
|
return next({ status: 409, message: error.message });
|
||||||
case NoSeasonsAvailableError:
|
case NoSeasonsAvailableError:
|
||||||
return next({ status: 202, message: error.message });
|
return next({ status: 202, message: error.message });
|
||||||
|
case BlacklistedMediaError:
|
||||||
|
return next({ status: 403, message: error.message });
|
||||||
default:
|
default:
|
||||||
return next({ status: 500, message: error.message });
|
return next({ status: 500, message: error.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,9 +123,13 @@ serviceRoutes.get<{ sonarrId: string }>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const systemStatus = await sonarr.getSystemStatus();
|
||||||
|
const sonarrMajorVersion = Number(systemStatus.version.split('.')[0]);
|
||||||
|
|
||||||
const profiles = await sonarr.getProfiles();
|
const profiles = await sonarr.getProfiles();
|
||||||
const rootFolders = await sonarr.getRootFolders();
|
const rootFolders = await sonarr.getRootFolders();
|
||||||
const languageProfiles = await sonarr.getLanguageProfiles();
|
const languageProfiles =
|
||||||
|
sonarrMajorVersion <= 3 ? await sonarr.getLanguageProfiles() : null;
|
||||||
const tags = await sonarr.getTags();
|
const tags = await sonarr.getTags();
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import { getHostname } from '@server/utils/getHostname';
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import gravatarUrl from 'gravatar-url';
|
|
||||||
import { escapeRegExp, merge, omit, set, sortBy } from 'lodash';
|
import { escapeRegExp, merge, omit, set, sortBy } from 'lodash';
|
||||||
import { rescheduleJob } from 'node-schedule';
|
import { rescheduleJob } from 'node-schedule';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -70,19 +69,19 @@ settingsRoutes.get('/main', (req, res, next) => {
|
|||||||
res.status(200).json(filteredMainSettings(req.user, settings.main));
|
res.status(200).json(filteredMainSettings(req.user, settings.main));
|
||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.post('/main', (req, res) => {
|
settingsRoutes.post('/main', async (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.main = merge(settings.main, req.body);
|
settings.main = merge(settings.main, req.body);
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
return res.status(200).json(settings.main);
|
return res.status(200).json(settings.main);
|
||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.post('/main/regenerate', (req, res, next) => {
|
settingsRoutes.post('/main/regenerate', async (req, res, next) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
const main = settings.regenerateApiKey();
|
const main = await settings.regenerateApiKey();
|
||||||
|
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return next({ status: 500, message: 'User missing from request.' });
|
return next({ status: 500, message: 'User missing from request.' });
|
||||||
@@ -119,7 +118,7 @@ settingsRoutes.post('/plex', async (req, res, next) => {
|
|||||||
settings.plex.machineId = result.MediaContainer.machineIdentifier;
|
settings.plex.machineId = result.MediaContainer.machineIdentifier;
|
||||||
settings.plex.name = result.MediaContainer.friendlyName;
|
settings.plex.name = result.MediaContainer.friendlyName;
|
||||||
|
|
||||||
settings.save();
|
await settings.save();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Something went wrong testing Plex connection', {
|
logger.error('Something went wrong testing Plex connection', {
|
||||||
label: 'API',
|
label: 'API',
|
||||||
@@ -232,7 +231,7 @@ settingsRoutes.get('/plex/library', async (req, res) => {
|
|||||||
...library,
|
...library,
|
||||||
enabled: enabledLibraries.includes(library.id),
|
enabled: enabledLibraries.includes(library.id),
|
||||||
}));
|
}));
|
||||||
settings.save();
|
await settings.save();
|
||||||
return res.status(200).json(settings.plex.libraries);
|
return res.status(200).json(settings.plex.libraries);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -262,7 +261,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 +269,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 ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -283,7 +282,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
Object.assign(settings.jellyfin, req.body);
|
Object.assign(settings.jellyfin, req.body);
|
||||||
settings.jellyfin.serverId = result.Id;
|
settings.jellyfin.serverId = result.Id;
|
||||||
settings.jellyfin.name = result.ServerName;
|
settings.jellyfin.name = result.ServerName;
|
||||||
settings.save();
|
await settings.save();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
logger.error('Something went wrong testing Jellyfin connection', {
|
logger.error('Something went wrong testing Jellyfin connection', {
|
||||||
@@ -318,13 +317,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 ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -371,25 +370,22 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
|||||||
...library,
|
...library,
|
||||||
enabled: enabledLibraries.includes(library.id),
|
enabled: enabledLibraries.includes(library.id),
|
||||||
}));
|
}));
|
||||||
settings.save();
|
await settings.save();
|
||||||
return res.status(200).json(settings.jellyfin.libraries);
|
return res.status(200).json(settings.jellyfin.libraries);
|
||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
||||||
const { externalHostname } = getSettings().jellyfin;
|
const settings = getSettings();
|
||||||
const jellyfinHost =
|
|
||||||
externalHostname && externalHostname.length > 0
|
|
||||||
? externalHostname
|
|
||||||
: getHostname();
|
|
||||||
|
|
||||||
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 ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -398,9 +394,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
|||||||
const users = resp.users.map((user) => ({
|
const users = resp.users.map((user) => ({
|
||||||
username: user.Name,
|
username: user.Name,
|
||||||
id: user.Id,
|
id: user.Id,
|
||||||
thumb: user.PrimaryImageTag
|
thumb: `/avatarproxy/${user.Id}`,
|
||||||
? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
|
|
||||||
: gravatarUrl(user.Name, { default: 'mm', size: 200 }),
|
|
||||||
email: user.Name,
|
email: user.Name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -440,7 +434,7 @@ settingsRoutes.post('/tautulli', async (req, res, next) => {
|
|||||||
throw new Error('Tautulli version not supported');
|
throw new Error('Tautulli version not supported');
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.save();
|
await settings.save();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Something went wrong testing Tautulli connection', {
|
logger.error('Something went wrong testing Tautulli connection', {
|
||||||
label: 'API',
|
label: 'API',
|
||||||
@@ -701,7 +695,7 @@ settingsRoutes.post<{ jobId: JobId }>(
|
|||||||
|
|
||||||
settingsRoutes.post<{ jobId: JobId }>(
|
settingsRoutes.post<{ jobId: JobId }>(
|
||||||
'/jobs/:jobId/schedule',
|
'/jobs/:jobId/schedule',
|
||||||
(req, res, next) => {
|
async (req, res, next) => {
|
||||||
const scheduledJob = scheduledJobs.find(
|
const scheduledJob = scheduledJobs.find(
|
||||||
(job) => job.id === req.params.jobId
|
(job) => job.id === req.params.jobId
|
||||||
);
|
);
|
||||||
@@ -715,7 +709,7 @@ settingsRoutes.post<{ jobId: JobId }>(
|
|||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
settings.jobs[scheduledJob.id].schedule = req.body.schedule;
|
settings.jobs[scheduledJob.id].schedule = req.body.schedule;
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
scheduledJob.cronSchedule = req.body.schedule;
|
scheduledJob.cronSchedule = req.body.schedule;
|
||||||
|
|
||||||
@@ -744,11 +738,13 @@ settingsRoutes.get('/cache', async (_req, res) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
|
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
|
||||||
|
const avatarImageCache = await ImageProxy.getImageStats('avatar');
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
apiCaches,
|
apiCaches,
|
||||||
imageCache: {
|
imageCache: {
|
||||||
tmdb: tmdbImageCache,
|
tmdb: tmdbImageCache,
|
||||||
|
avatar: avatarImageCache,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -770,11 +766,11 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
|
|||||||
settingsRoutes.post(
|
settingsRoutes.post(
|
||||||
'/initialize',
|
'/initialize',
|
||||||
isAuthenticated(Permission.ADMIN),
|
isAuthenticated(Permission.ADMIN),
|
||||||
(_req, res) => {
|
async (_req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.public.initialized = true;
|
settings.public.initialized = true;
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
return res.status(200).json(settings.public);
|
return res.status(200).json(settings.public);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ notificationRoutes.get('/discord', (_req, res) => {
|
|||||||
res.status(200).json(settings.notifications.agents.discord);
|
res.status(200).json(settings.notifications.agents.discord);
|
||||||
});
|
});
|
||||||
|
|
||||||
notificationRoutes.post('/discord', (req, res) => {
|
notificationRoutes.post('/discord', async (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.discord = req.body;
|
settings.notifications.agents.discord = req.body;
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
res.status(200).json(settings.notifications.agents.discord);
|
res.status(200).json(settings.notifications.agents.discord);
|
||||||
});
|
});
|
||||||
@@ -65,11 +65,11 @@ notificationRoutes.get('/slack', (_req, res) => {
|
|||||||
res.status(200).json(settings.notifications.agents.slack);
|
res.status(200).json(settings.notifications.agents.slack);
|
||||||
});
|
});
|
||||||
|
|
||||||
notificationRoutes.post('/slack', (req, res) => {
|
notificationRoutes.post('/slack', async (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.slack = req.body;
|
settings.notifications.agents.slack = req.body;
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
res.status(200).json(settings.notifications.agents.slack);
|
res.status(200).json(settings.notifications.agents.slack);
|
||||||
});
|
});
|
||||||
@@ -99,11 +99,11 @@ notificationRoutes.get('/telegram', (_req, res) => {
|
|||||||
res.status(200).json(settings.notifications.agents.telegram);
|
res.status(200).json(settings.notifications.agents.telegram);
|
||||||
});
|
});
|
||||||
|
|
||||||
notificationRoutes.post('/telegram', (req, res) => {
|
notificationRoutes.post('/telegram', async (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.telegram = req.body;
|
settings.notifications.agents.telegram = req.body;
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
res.status(200).json(settings.notifications.agents.telegram);
|
res.status(200).json(settings.notifications.agents.telegram);
|
||||||
});
|
});
|
||||||
@@ -133,11 +133,11 @@ notificationRoutes.get('/pushbullet', (_req, res) => {
|
|||||||
res.status(200).json(settings.notifications.agents.pushbullet);
|
res.status(200).json(settings.notifications.agents.pushbullet);
|
||||||
});
|
});
|
||||||
|
|
||||||
notificationRoutes.post('/pushbullet', (req, res) => {
|
notificationRoutes.post('/pushbullet', async (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.pushbullet = req.body;
|
settings.notifications.agents.pushbullet = req.body;
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
res.status(200).json(settings.notifications.agents.pushbullet);
|
res.status(200).json(settings.notifications.agents.pushbullet);
|
||||||
});
|
});
|
||||||
@@ -167,11 +167,11 @@ notificationRoutes.get('/pushover', (_req, res) => {
|
|||||||
res.status(200).json(settings.notifications.agents.pushover);
|
res.status(200).json(settings.notifications.agents.pushover);
|
||||||
});
|
});
|
||||||
|
|
||||||
notificationRoutes.post('/pushover', (req, res) => {
|
notificationRoutes.post('/pushover', async (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.pushover = req.body;
|
settings.notifications.agents.pushover = req.body;
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
res.status(200).json(settings.notifications.agents.pushover);
|
res.status(200).json(settings.notifications.agents.pushover);
|
||||||
});
|
});
|
||||||
@@ -201,11 +201,11 @@ notificationRoutes.get('/email', (_req, res) => {
|
|||||||
res.status(200).json(settings.notifications.agents.email);
|
res.status(200).json(settings.notifications.agents.email);
|
||||||
});
|
});
|
||||||
|
|
||||||
notificationRoutes.post('/email', (req, res) => {
|
notificationRoutes.post('/email', async (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.email = req.body;
|
settings.notifications.agents.email = req.body;
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
res.status(200).json(settings.notifications.agents.email);
|
res.status(200).json(settings.notifications.agents.email);
|
||||||
});
|
});
|
||||||
@@ -235,11 +235,11 @@ notificationRoutes.get('/webpush', (_req, res) => {
|
|||||||
res.status(200).json(settings.notifications.agents.webpush);
|
res.status(200).json(settings.notifications.agents.webpush);
|
||||||
});
|
});
|
||||||
|
|
||||||
notificationRoutes.post('/webpush', (req, res) => {
|
notificationRoutes.post('/webpush', async (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.webpush = req.body;
|
settings.notifications.agents.webpush = req.body;
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
res.status(200).json(settings.notifications.agents.webpush);
|
res.status(200).json(settings.notifications.agents.webpush);
|
||||||
});
|
});
|
||||||
@@ -284,7 +284,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
|
|||||||
res.status(200).json(response);
|
res.status(200).json(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
notificationRoutes.post('/webhook', (req, res, next) => {
|
notificationRoutes.post('/webhook', async (req, res, next) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
try {
|
try {
|
||||||
JSON.parse(req.body.options.jsonPayload);
|
JSON.parse(req.body.options.jsonPayload);
|
||||||
@@ -300,7 +300,7 @@ notificationRoutes.post('/webhook', (req, res, next) => {
|
|||||||
authHeader: req.body.options.authHeader,
|
authHeader: req.body.options.authHeader,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
res.status(200).json(settings.notifications.agents.webhook);
|
res.status(200).json(settings.notifications.agents.webhook);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -351,11 +351,11 @@ notificationRoutes.get('/lunasea', (_req, res) => {
|
|||||||
res.status(200).json(settings.notifications.agents.lunasea);
|
res.status(200).json(settings.notifications.agents.lunasea);
|
||||||
});
|
});
|
||||||
|
|
||||||
notificationRoutes.post('/lunasea', (req, res) => {
|
notificationRoutes.post('/lunasea', async (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.lunasea = req.body;
|
settings.notifications.agents.lunasea = req.body;
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
res.status(200).json(settings.notifications.agents.lunasea);
|
res.status(200).json(settings.notifications.agents.lunasea);
|
||||||
});
|
});
|
||||||
@@ -385,11 +385,11 @@ notificationRoutes.get('/gotify', (_req, res) => {
|
|||||||
res.status(200).json(settings.notifications.agents.gotify);
|
res.status(200).json(settings.notifications.agents.gotify);
|
||||||
});
|
});
|
||||||
|
|
||||||
notificationRoutes.post('/gotify', (req, res) => {
|
notificationRoutes.post('/gotify', async (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.gotify = req.body;
|
settings.notifications.agents.gotify = req.body;
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
res.status(200).json(settings.notifications.agents.gotify);
|
res.status(200).json(settings.notifications.agents.gotify);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ radarrRoutes.get('/', (_req, res) => {
|
|||||||
res.status(200).json(settings.radarr);
|
res.status(200).json(settings.radarr);
|
||||||
});
|
});
|
||||||
|
|
||||||
radarrRoutes.post('/', (req, res) => {
|
radarrRoutes.post('/', async (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
const newRadarr = req.body as RadarrSettings;
|
const newRadarr = req.body as RadarrSettings;
|
||||||
@@ -31,7 +31,7 @@ radarrRoutes.post('/', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
settings.radarr = [...settings.radarr, newRadarr];
|
settings.radarr = [...settings.radarr, newRadarr];
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
return res.status(201).json(newRadarr);
|
return res.status(201).json(newRadarr);
|
||||||
});
|
});
|
||||||
@@ -76,7 +76,7 @@ radarrRoutes.post<
|
|||||||
|
|
||||||
radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>(
|
radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>(
|
||||||
'/:id',
|
'/:id',
|
||||||
(req, res, next) => {
|
async (req, res, next) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
const radarrIndex = settings.radarr.findIndex(
|
const radarrIndex = settings.radarr.findIndex(
|
||||||
@@ -102,7 +102,7 @@ radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>(
|
|||||||
...req.body,
|
...req.body,
|
||||||
id: Number(req.params.id),
|
id: Number(req.params.id),
|
||||||
} as RadarrSettings;
|
} as RadarrSettings;
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
return res.status(200).json(settings.radarr[radarrIndex]);
|
return res.status(200).json(settings.radarr[radarrIndex]);
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => {
|
radarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
const radarrIndex = settings.radarr.findIndex(
|
const radarrIndex = settings.radarr.findIndex(
|
||||||
@@ -146,7 +146,7 @@ radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removed = settings.radarr.splice(radarrIndex, 1);
|
const removed = settings.radarr.splice(radarrIndex, 1);
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
return res.status(200).json(removed[0]);
|
return res.status(200).json(removed[0]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ sonarrRoutes.get('/', (_req, res) => {
|
|||||||
res.status(200).json(settings.sonarr);
|
res.status(200).json(settings.sonarr);
|
||||||
});
|
});
|
||||||
|
|
||||||
sonarrRoutes.post('/', (req, res) => {
|
sonarrRoutes.post('/', async (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
const newSonarr = req.body as SonarrSettings;
|
const newSonarr = req.body as SonarrSettings;
|
||||||
@@ -31,7 +31,7 @@ sonarrRoutes.post('/', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
settings.sonarr = [...settings.sonarr, newSonarr];
|
settings.sonarr = [...settings.sonarr, newSonarr];
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
return res.status(201).json(newSonarr);
|
return res.status(201).json(newSonarr);
|
||||||
});
|
});
|
||||||
@@ -43,13 +43,14 @@ sonarrRoutes.post('/test', async (req, res, next) => {
|
|||||||
url: SonarrAPI.buildUrl(req.body, '/api/v3'),
|
url: SonarrAPI.buildUrl(req.body, '/api/v3'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const urlBase = await sonarr
|
const systemStatus = await sonarr.getSystemStatus();
|
||||||
.getSystemStatus()
|
const sonarrMajorVersion = Number(systemStatus.version.split('.')[0]);
|
||||||
.then((value) => value.urlBase)
|
|
||||||
.catch(() => req.body.baseUrl);
|
const urlBase = systemStatus.urlBase;
|
||||||
const profiles = await sonarr.getProfiles();
|
const profiles = await sonarr.getProfiles();
|
||||||
const folders = await sonarr.getRootFolders();
|
const folders = await sonarr.getRootFolders();
|
||||||
const languageProfiles = await sonarr.getLanguageProfiles();
|
const languageProfiles =
|
||||||
|
sonarrMajorVersion <= 3 ? await sonarr.getLanguageProfiles() : null;
|
||||||
const tags = await sonarr.getTags();
|
const tags = await sonarr.getTags();
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
@@ -72,7 +73,7 @@ sonarrRoutes.post('/test', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
sonarrRoutes.put<{ id: string }>('/:id', (req, res) => {
|
sonarrRoutes.put<{ id: string }>('/:id', async (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
const sonarrIndex = settings.sonarr.findIndex(
|
const sonarrIndex = settings.sonarr.findIndex(
|
||||||
@@ -100,12 +101,12 @@ sonarrRoutes.put<{ id: string }>('/:id', (req, res) => {
|
|||||||
...req.body,
|
...req.body,
|
||||||
id: Number(req.params.id),
|
id: Number(req.params.id),
|
||||||
} as SonarrSettings;
|
} as SonarrSettings;
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
return res.status(200).json(settings.sonarr[sonarrIndex]);
|
return res.status(200).json(settings.sonarr[sonarrIndex]);
|
||||||
});
|
});
|
||||||
|
|
||||||
sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
|
sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
const sonarrIndex = settings.sonarr.findIndex(
|
const sonarrIndex = settings.sonarr.findIndex(
|
||||||
@@ -119,7 +120,7 @@ sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removed = settings.sonarr.splice(sonarrIndex, 1);
|
const removed = settings.sonarr.splice(sonarrIndex, 1);
|
||||||
settings.save();
|
await settings.save();
|
||||||
|
|
||||||
return res.status(200).json(removed[0]);
|
return res.status(200).json(removed[0]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import JellyfinAPI from '@server/api/jellyfin';
|
|||||||
import PlexTvAPI from '@server/api/plextv';
|
import PlexTvAPI from '@server/api/plextv';
|
||||||
import TautulliAPI from '@server/api/tautulli';
|
import TautulliAPI from '@server/api/tautulli';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import { UserType } from '@server/constants/user';
|
import { UserType } from '@server/constants/user';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
@@ -33,15 +34,35 @@ router.get('/', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const pageSize = req.query.take ? Number(req.query.take) : 10;
|
const pageSize = req.query.take ? Number(req.query.take) : 10;
|
||||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||||
|
const q = req.query.q ? req.query.q.toString().toLowerCase() : '';
|
||||||
let query = getRepository(User).createQueryBuilder('user');
|
let query = getRepository(User).createQueryBuilder('user');
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
query = query.where(
|
||||||
|
'LOWER(user.username) LIKE :q OR LOWER(user.email) LIKE :q OR LOWER(user.plexUsername) LIKE :q OR LOWER(user.jellyfinUsername) LIKE :q',
|
||||||
|
{ q: `%${q}%` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
switch (req.query.sort) {
|
switch (req.query.sort) {
|
||||||
case 'updated':
|
case 'updated':
|
||||||
query = query.orderBy('user.updatedAt', 'DESC');
|
query = query.orderBy('user.updatedAt', 'DESC');
|
||||||
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 +111,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 +130,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 +140,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,29 +510,20 @@ 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 ?? '');
|
||||||
|
|
||||||
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
||||||
const createdUsers: User[] = [];
|
const createdUsers: User[] = [];
|
||||||
const { externalHostname } = getSettings().jellyfin;
|
|
||||||
const hostname = getHostname();
|
|
||||||
|
|
||||||
const jellyfinHost =
|
|
||||||
externalHostname && externalHostname.length > 0
|
|
||||||
? externalHostname
|
|
||||||
: hostname;
|
|
||||||
|
|
||||||
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
||||||
const jellyfinUsers = await jellyfinClient.getUsers();
|
const jellyfinUsers = await jellyfinClient.getUsers();
|
||||||
@@ -534,13 +547,11 @@ router.post(
|
|||||||
).toString('base64'),
|
).toString('base64'),
|
||||||
email: jellyfinUser?.Name,
|
email: jellyfinUser?.Name,
|
||||||
permissions: settings.main.defaultPermissions,
|
permissions: settings.main.defaultPermissions,
|
||||||
avatar: jellyfinUser?.PrimaryImageTag
|
avatar: `/avatarproxy/${jellyfinUser?.Id}`,
|
||||||
? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
|
userType:
|
||||||
: gravatarUrl(jellyfinUser?.Name ?? '', {
|
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||||
default: 'mm',
|
? UserType.JELLYFIN
|
||||||
size: 200,
|
: UserType.EMBY,
|
||||||
}),
|
|
||||||
userType: UserType.JELLYFIN,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await userRepository.save(newUser);
|
await userRepository.save(newUser);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
|
import { UserType } from '@server/constants/user';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import { UserSettings } from '@server/entity/UserSettings';
|
import { UserSettings } from '@server/entity/UserSettings';
|
||||||
@@ -9,6 +11,7 @@ import { Permission } from '@server/lib/permissions';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
|
import { ApiError } from '@server/types/error';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { canMakePermissionsChange } from '.';
|
import { canMakePermissionsChange } from '.';
|
||||||
|
|
||||||
@@ -97,8 +100,36 @@ userSettingsRoutes.post<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oldEmail = user.email;
|
||||||
|
const oldUsername = user.username;
|
||||||
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;
|
||||||
|
}
|
||||||
|
// Edge case for local users, because they have no Jellyfin username to fall back on
|
||||||
|
// if the email is not provided
|
||||||
|
if (user.userType === UserType.LOCAL) {
|
||||||
|
if (req.body.email) {
|
||||||
|
user.email = req.body.email;
|
||||||
|
if (
|
||||||
|
!user.username &&
|
||||||
|
user.email !== oldEmail &&
|
||||||
|
!oldEmail.includes('@')
|
||||||
|
) {
|
||||||
|
user.username = oldEmail;
|
||||||
|
}
|
||||||
|
} else if (req.body.username) {
|
||||||
|
user.email = oldUsername || user.email;
|
||||||
|
user.username = req.body.username;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await userRepository.findOne({
|
||||||
|
where: { email: user.email },
|
||||||
|
});
|
||||||
|
if (oldEmail !== user.email && existingUser) {
|
||||||
|
throw new ApiError(400, ApiErrorCode.InvalidEmail);
|
||||||
|
}
|
||||||
|
|
||||||
// Update quota values only if the user has the correct permissions
|
// Update quota values only if the user has the correct permissions
|
||||||
if (
|
if (
|
||||||
@@ -143,7 +174,14 @@ userSettingsRoutes.post<
|
|||||||
email: savedUser.email,
|
email: savedUser.email,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next({ status: 500, message: e.message });
|
if (e.errorCode) {
|
||||||
|
return next({
|
||||||
|
status: e.statusCode,
|
||||||
|
message: e.errorCode,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return next({ status: 500, message: e.message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { existsSync } from 'fs';
|
import { accessSync, existsSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const CONFIG_PATH = process.env.CONFIG_DIRECTORY
|
const CONFIG_PATH = process.env.CONFIG_DIRECTORY
|
||||||
@@ -14,3 +14,12 @@ export const appDataStatus = (): boolean => {
|
|||||||
export const appDataPath = (): string => {
|
export const appDataPath = (): string => {
|
||||||
return CONFIG_PATH;
|
return CONFIG_PATH;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const appDataPermissions = (): boolean => {
|
||||||
|
try {
|
||||||
|
accessSync(CONFIG_PATH);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
111
server/utils/customProxyAgent.ts
Normal file
111
server/utils/customProxyAgent.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import type { ProxySettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import type { Dispatcher } from 'undici';
|
||||||
|
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||||
|
|
||||||
|
export default async function createCustomProxyAgent(
|
||||||
|
proxySettings: ProxySettings
|
||||||
|
) {
|
||||||
|
const defaultAgent = new Agent();
|
||||||
|
|
||||||
|
const skipUrl = (url: string) => {
|
||||||
|
const hostname = new URL(url).hostname;
|
||||||
|
|
||||||
|
if (proxySettings.bypassLocalAddresses && isLocalAddress(hostname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const address of proxySettings.bypassFilter.split(',')) {
|
||||||
|
const trimmedAddress = address.trim();
|
||||||
|
if (!trimmedAddress) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedAddress.startsWith('*')) {
|
||||||
|
const domain = trimmedAddress.slice(1);
|
||||||
|
if (hostname.endsWith(domain)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (hostname === trimmedAddress) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const noProxyInterceptor = (
|
||||||
|
dispatch: Dispatcher['dispatch']
|
||||||
|
): Dispatcher['dispatch'] => {
|
||||||
|
return (opts, handler) => {
|
||||||
|
const url = opts.origin?.toString();
|
||||||
|
return url && skipUrl(url)
|
||||||
|
? defaultAgent.dispatch(opts, handler)
|
||||||
|
: dispatch(opts, handler);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const token =
|
||||||
|
proxySettings.user && proxySettings.password
|
||||||
|
? `Basic ${Buffer.from(
|
||||||
|
`${proxySettings.user}:${proxySettings.password}`
|
||||||
|
).toString('base64')}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proxyAgent = new ProxyAgent({
|
||||||
|
uri:
|
||||||
|
(proxySettings.useSsl ? 'https://' : 'http://') +
|
||||||
|
proxySettings.hostname +
|
||||||
|
':' +
|
||||||
|
proxySettings.port,
|
||||||
|
token,
|
||||||
|
interceptors: {
|
||||||
|
Client: [noProxyInterceptor],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setGlobalDispatcher(proxyAgent);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to connect to the proxy: ' + e.message, {
|
||||||
|
label: 'Proxy',
|
||||||
|
});
|
||||||
|
setGlobalDispatcher(defaultAgent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://www.google.com', { method: 'HEAD' });
|
||||||
|
if (res.ok) {
|
||||||
|
logger.debug('HTTP(S) proxy connected successfully', { label: 'Proxy' });
|
||||||
|
} else {
|
||||||
|
logger.error('Proxy responded, but with a non-OK status: ' + res.status, {
|
||||||
|
label: 'Proxy',
|
||||||
|
});
|
||||||
|
setGlobalDispatcher(defaultAgent);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
'Failed to connect to the proxy: ' + e.message + ': ' + e.cause,
|
||||||
|
{ label: 'Proxy' }
|
||||||
|
);
|
||||||
|
setGlobalDispatcher(defaultAgent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalAddress(hostname: string) {
|
||||||
|
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateIpRanges = [
|
||||||
|
/^10\./, // 10.x.x.x
|
||||||
|
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.x.x - 172.31.x.x
|
||||||
|
/^192\.168\./, // 192.168.x.x
|
||||||
|
];
|
||||||
|
if (privateIpRanges.some((regex) => regex.test(hostname))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
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();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ class RestartFlag {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
this.settings.csrfProtection !== settings.csrfProtection ||
|
this.settings.csrfProtection !== settings.csrfProtection ||
|
||||||
this.settings.trustProxy !== settings.trustProxy
|
this.settings.trustProxy !== settings.trustProxy ||
|
||||||
|
this.settings.proxy.enabled !== settings.proxy.enabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
src/assets/services/emby-icon-only.svg
Normal file
47
src/assets/services/emby-icon-only.svg
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
id="svg2"
|
||||||
|
viewBox="0 0 712.60077 712.5481"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<defs
|
||||||
|
id="defs4" />
|
||||||
|
<metadata
|
||||||
|
id="metadata7">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<rect
|
||||||
|
style="opacity:0;fill:#ffffff;stroke-width:4.12543"
|
||||||
|
id="rect249"
|
||||||
|
width="712.60077"
|
||||||
|
height="712.5481"
|
||||||
|
x="-0.00071160076"
|
||||||
|
y="2.0223413e-11" />
|
||||||
|
<rect
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="rect289"
|
||||||
|
width="230.18982"
|
||||||
|
height="229.82355"
|
||||||
|
x="241.20476"
|
||||||
|
y="241.36227" />
|
||||||
|
<g
|
||||||
|
id="layer1"
|
||||||
|
transform="matrix(0.70249853,0,0,0.70249853,88.770447,96.84571)">
|
||||||
|
<path
|
||||||
|
id="path3427"
|
||||||
|
d="m 327.06546,642.18589 c -45.39663,-45.86009 -82.73776,-83.3683 -82.98029,-83.3516 -0.24253,0.0167 -7.23324,6.65975 -15.53493,14.7623 l -15.09396,14.73193 -40.13624,-40.38805 C 151.24511,525.72706 108.73555,482.86504 78.854363,452.69158 l -54.329437,-54.86086 83.720394,-82.90796 83.72039,-82.90797 -15.19316,-15.20441 -15.19315,-15.20443 95.18008,-94.29313 c 52.34904,-51.86121 95.35849,-94.293118 95.57653,-94.293118 0.21805,0 37.39519,37.357576 82.61589,83.016832 45.22068,45.659256 82.53772,83.131956 82.92673,83.272666 0.38901,0.14071 7.46336,-6.49498 15.72077,-14.746 l 15.01348,-15.00184 7.14591,7.19902 c 73.95232,74.50189 181.50599,183.56427 181.36678,183.9109 -0.10065,0.25064 -37.54056,37.44106 -83.19981,82.64536 -45.65926,45.2043 -83.10802,82.41429 -83.21946,82.68884 -0.11145,0.27456 6.50478,7.34753 14.70272,15.71771 l 14.90534,15.21851 -15.3888,15.28883 c -21.09609,20.95904 -162.95155,161.27018 -169.79551,167.947 l -5.52526,5.39033 z m 89.8523,-204.1566 c 64.84836,-37.53366 117.81919,-68.54793 117.71294,-68.92058 -0.15927,-0.55862 -233.55022,-136.2489 -236.27084,-137.3646 -0.68441,-0.28068 -0.85761,27.45642 -0.85761,137.33982 0,99.83563 0.20749,137.62237 0.75471,137.43996 0.41509,-0.13837 53.81245,-30.96093 118.6608,-68.4946 z"
|
||||||
|
style="fill:#52b54b;fill-opacity:1;stroke:none" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user