Compare commits
43 Commits
preview-mo
...
pr-934
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd18a9fe9a | ||
|
|
70aab8b0a9 | ||
|
|
1c176dc71e | ||
|
|
c7d2386799 | ||
|
|
b04f280fbd | ||
|
|
6b0909264d | ||
|
|
7923fc67e8 | ||
|
|
afed6e8879 | ||
|
|
341c7171cd | ||
|
|
3ab184be20 | ||
|
|
7be97a0e12 | ||
|
|
78e6fdb7bf | ||
|
|
fc95e9da4b | ||
|
|
e6b5707190 | ||
|
|
1218876fbf | ||
|
|
b9336fc589 | ||
|
|
20762143db | ||
|
|
9d10efd277 | ||
|
|
ba81ed25e2 | ||
|
|
05a4adf369 | ||
|
|
1449883629 | ||
|
|
9f4329e243 | ||
|
|
5743105db4 | ||
|
|
fce8231710 | ||
|
|
53be59ad51 | ||
|
|
7b8adf5bdf | ||
|
|
7ee45ef6e1 | ||
|
|
50488db05a | ||
|
|
a132105b45 | ||
|
|
cb172cc737 | ||
|
|
cee3be2c64 | ||
|
|
8ecb459808 | ||
|
|
372b3649b5 | ||
|
|
ee472e11af | ||
|
|
1fd3a00284 | ||
|
|
c97c40e04c | ||
|
|
12f4d78692 | ||
|
|
18e3f140eb | ||
|
|
226e910728 | ||
|
|
fb6140972a | ||
|
|
9499b364af | ||
|
|
a15f0ec029 | ||
|
|
5c269368aa |
@@ -439,15 +439,6 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"code"
|
"code"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "M0NsTeRRR",
|
|
||||||
"name": "Ludovic Ortega",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/37785089?v=4",
|
|
||||||
"profile": "https://github.com/M0NsTeRRR",
|
|
||||||
"contributions": [
|
|
||||||
"security"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
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: ['bug', 'awaiting triage']
|
labels: ['type: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: ['enhancement', 'awaiting triage']
|
labels: ['type:enhancement', 'awaiting-triage']
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,7 +34,6 @@ 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,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://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
<a href="http://jellyseerr.borgcube.de/engage/jellyseerr/"><img src="http://jellyseerr.borgcube.de/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
||||||
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
||||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-48-orange.svg"/></a>
|
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-47-orange.svg"/></a>
|
||||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||||
|
|
||||||
**Jellyseerr** is a free and open source software application for managing requests for your media library.
|
**Jellyseerr** is a free and open source software application for managing requests for your media library.
|
||||||
@@ -146,7 +146,6 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<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/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/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/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>
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ 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,12 +12,49 @@ 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
|
||||||
@@ -40,6 +77,8 @@ 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.
|
||||||
@@ -195,6 +234,33 @@ 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
|
||||||
|
npm install -g win-node-env
|
||||||
|
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
|
||||||
@@ -218,6 +284,8 @@ 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.
|
||||||
@@ -245,7 +313,6 @@ 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.
|
||||||
|
|||||||
@@ -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-derivation">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">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;
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ 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--discover-language) to suit their own preferences.
|
Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region-and-discover-language) to suit their own preferences.
|
||||||
|
|
||||||
### Movie Request Limit & Series Request Limit
|
### Movie Request Limit & Series Request Limit
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ module.exports = {
|
|||||||
commitTag: process.env.COMMIT_TAG || 'local',
|
commitTag: process.env.COMMIT_TAG || 'local',
|
||||||
forceIpv4First: process.env.FORCE_IPV4_FIRST === 'true' ? 'true' : 'false',
|
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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ 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:
|
||||||
@@ -48,19 +46,6 @@ 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:
|
||||||
@@ -1988,9 +1973,6 @@ 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
|
||||||
@@ -2793,15 +2775,6 @@ paths:
|
|||||||
imageCount:
|
imageCount:
|
||||||
type: number
|
type: number
|
||||||
example: 123
|
example: 123
|
||||||
avatar:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
size:
|
|
||||||
type: number
|
|
||||||
example: 123456
|
|
||||||
imageCount:
|
|
||||||
type: number
|
|
||||||
example: 123
|
|
||||||
apiCaches:
|
apiCaches:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -3613,8 +3586,6 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
email:
|
email:
|
||||||
type: string
|
type: string
|
||||||
serverType:
|
|
||||||
type: number
|
|
||||||
required:
|
required:
|
||||||
- username
|
- username
|
||||||
- password
|
- password
|
||||||
@@ -4069,94 +4040,6 @@ 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}:
|
|
||||||
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
|
||||||
@@ -4979,11 +4862,6 @@ 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
|
||||||
|
|||||||
@@ -62,7 +62,6 @@
|
|||||||
"formik": "^2.4.6",
|
"formik": "^2.4.6",
|
||||||
"gravatar-url": "3.1.0",
|
"gravatar-url": "3.1.0",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"mime": "3",
|
|
||||||
"next": "^14.2.4",
|
"next": "^14.2.4",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"node-gyp": "9.3.1",
|
"node-gyp": "9.3.1",
|
||||||
@@ -93,8 +92,7 @@
|
|||||||
"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.11",
|
"typeorm": "0.3.12",
|
||||||
"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 +119,6 @@
|
|||||||
"@types/express": "4.17.17",
|
"@types/express": "4.17.17",
|
||||||
"@types/express-session": "1.17.6",
|
"@types/express-session": "1.17.6",
|
||||||
"@types/lodash": "4.14.191",
|
"@types/lodash": "4.14.191",
|
||||||
"@types/mime": "3",
|
|
||||||
"@types/node": "20.14.8",
|
"@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",
|
||||||
|
|||||||
90
pnpm-lock.yaml
generated
90
pnpm-lock.yaml
generated
@@ -49,7 +49,7 @@ importers:
|
|||||||
version: 2.11.0
|
version: 2.11.0
|
||||||
connect-typeorm:
|
connect-typeorm:
|
||||||
specifier: 1.1.4
|
specifier: 1.1.4
|
||||||
version: 1.1.4(typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)))
|
version: 1.1.4(typeorm@0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)))
|
||||||
cookie-parser:
|
cookie-parser:
|
||||||
specifier: 1.4.6
|
specifier: 1.4.6
|
||||||
version: 1.4.6
|
version: 1.4.6
|
||||||
@@ -98,9 +98,6 @@ importers:
|
|||||||
lodash:
|
lodash:
|
||||||
specifier: 4.17.21
|
specifier: 4.17.21
|
||||||
version: 4.17.21
|
version: 4.17.21
|
||||||
mime:
|
|
||||||
specifier: '3'
|
|
||||||
version: 3.0.0
|
|
||||||
next:
|
next:
|
||||||
specifier: ^14.2.4
|
specifier: ^14.2.4
|
||||||
version: 14.2.4(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 14.2.4(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -192,11 +189,8 @@ importers:
|
|||||||
specifier: 2.2.5
|
specifier: 2.2.5
|
||||||
version: 2.2.5(react@18.3.1)
|
version: 2.2.5(react@18.3.1)
|
||||||
typeorm:
|
typeorm:
|
||||||
specifier: 0.3.11
|
specifier: 0.3.12
|
||||||
version: 0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
|
version: 0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
|
||||||
undici:
|
|
||||||
specifier: ^6.20.1
|
|
||||||
version: 6.20.1
|
|
||||||
web-push:
|
web-push:
|
||||||
specifier: 3.5.0
|
specifier: 3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
@@ -270,9 +264,6 @@ importers:
|
|||||||
'@types/lodash':
|
'@types/lodash':
|
||||||
specifier: 4.14.191
|
specifier: 4.14.191
|
||||||
version: 4.14.191
|
version: 4.14.191
|
||||||
'@types/mime':
|
|
||||||
specifier: '3'
|
|
||||||
version: 3.0.4
|
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 20.14.8
|
specifier: 20.14.8
|
||||||
version: 20.14.8
|
version: 20.14.8
|
||||||
@@ -2857,9 +2848,6 @@ packages:
|
|||||||
'@types/mime@1.3.5':
|
'@types/mime@1.3.5':
|
||||||
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
|
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
|
||||||
|
|
||||||
'@types/mime@3.0.4':
|
|
||||||
resolution: {integrity: sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==}
|
|
||||||
|
|
||||||
'@types/minimatch@3.0.5':
|
'@types/minimatch@3.0.5':
|
||||||
resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}
|
resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}
|
||||||
|
|
||||||
@@ -4267,6 +4255,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==}
|
resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==}
|
||||||
engines: {node: '>=0.11'}
|
engines: {node: '>=0.11'}
|
||||||
|
|
||||||
|
date-fns@2.30.0:
|
||||||
|
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
|
||||||
|
engines: {node: '>=0.11'}
|
||||||
|
|
||||||
dateformat@3.0.3:
|
dateformat@3.0.3:
|
||||||
resolution: {integrity: sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==}
|
resolution: {integrity: sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==}
|
||||||
|
|
||||||
@@ -5388,8 +5380,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
https-proxy-agent@7.0.5:
|
https-proxy-agent@7.0.4:
|
||||||
resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==}
|
resolution: {integrity: sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
human-signals@1.1.1:
|
human-signals@1.1.1:
|
||||||
@@ -6553,6 +6545,11 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
mkdirp@2.1.6:
|
||||||
|
resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
modify-values@1.0.1:
|
modify-values@1.0.1:
|
||||||
resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==}
|
resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -7724,6 +7721,9 @@ packages:
|
|||||||
reflect-metadata@0.1.13:
|
reflect-metadata@0.1.13:
|
||||||
resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==}
|
resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==}
|
||||||
|
|
||||||
|
reflect-metadata@0.1.14:
|
||||||
|
resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==}
|
||||||
|
|
||||||
reflect.getprototypeof@1.0.6:
|
reflect.getprototypeof@1.0.6:
|
||||||
resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==}
|
resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -8661,8 +8661,8 @@ packages:
|
|||||||
typedarray@0.0.6:
|
typedarray@0.0.6:
|
||||||
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||||
|
|
||||||
typeorm@0.3.11:
|
typeorm@0.3.12:
|
||||||
resolution: {integrity: sha512-pzdOyWbVuz/z8Ww6gqvBW4nylsM0KLdUCDExr2gR20/x1khGSVxQkjNV/3YqliG90jrWzrknYbYscpk8yxFJVg==}
|
resolution: {integrity: sha512-sYSxBmCf1nJLLTcYtwqZ+lQIRtLPyUoO93rHTOKk9vJCyT4UfRtU7oRsJvfvKP3nnZTD1hzz2SEy2zwPEN6OyA==}
|
||||||
engines: {node: '>= 12.9.0'}
|
engines: {node: '>= 12.9.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -8673,7 +8673,7 @@ packages:
|
|||||||
ioredis: ^5.0.4
|
ioredis: ^5.0.4
|
||||||
mongodb: ^3.6.0
|
mongodb: ^3.6.0
|
||||||
mssql: ^7.3.0
|
mssql: ^7.3.0
|
||||||
mysql2: ^2.2.5
|
mysql2: ^2.2.5 || ^3.0.1
|
||||||
oracledb: ^5.1.0
|
oracledb: ^5.1.0
|
||||||
pg: ^8.5.1
|
pg: ^8.5.1
|
||||||
pg-native: ^3.0.0
|
pg-native: ^3.0.0
|
||||||
@@ -8759,10 +8759,6 @@ packages:
|
|||||||
undici-types@5.26.5:
|
undici-types@5.26.5:
|
||||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||||
|
|
||||||
undici@6.20.1:
|
|
||||||
resolution: {integrity: sha512-AjQF1QsmqfJys+LXfGTNum+qw4S88CojRInG/6t31W/1fk6G59s92bnAvGz5Cmur+kQv2SURXEvvudLmbrE8QA==}
|
|
||||||
engines: {node: '>=18.17'}
|
|
||||||
|
|
||||||
unicode-canonical-property-names-ecmascript@2.0.0:
|
unicode-canonical-property-names-ecmascript@2.0.0:
|
||||||
resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==}
|
resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -10840,7 +10836,7 @@ snapshots:
|
|||||||
nopt: 5.0.0
|
nopt: 5.0.0
|
||||||
npmlog: 5.0.1
|
npmlog: 5.0.1
|
||||||
rimraf: 3.0.2
|
rimraf: 3.0.2
|
||||||
semver: 7.3.8
|
semver: 7.6.2
|
||||||
tar: 6.2.1
|
tar: 6.2.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- encoding
|
- encoding
|
||||||
@@ -10915,13 +10911,13 @@ snapshots:
|
|||||||
'@npmcli/fs@1.1.1':
|
'@npmcli/fs@1.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@gar/promisify': 1.1.3
|
'@gar/promisify': 1.1.3
|
||||||
semver: 7.3.8
|
semver: 7.6.2
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@npmcli/fs@2.1.2':
|
'@npmcli/fs@2.1.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@gar/promisify': 1.1.3
|
'@gar/promisify': 1.1.3
|
||||||
semver: 7.3.8
|
semver: 7.6.2
|
||||||
|
|
||||||
'@npmcli/move-file@1.1.2':
|
'@npmcli/move-file@1.1.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -12305,7 +12301,7 @@ snapshots:
|
|||||||
fs-extra: 11.2.0
|
fs-extra: 11.2.0
|
||||||
globby: 11.1.0
|
globby: 11.1.0
|
||||||
http-proxy-agent: 7.0.2
|
http-proxy-agent: 7.0.2
|
||||||
https-proxy-agent: 7.0.5
|
https-proxy-agent: 7.0.4
|
||||||
issue-parser: 6.0.0
|
issue-parser: 6.0.0
|
||||||
lodash: 4.17.21
|
lodash: 4.17.21
|
||||||
mime: 3.0.0
|
mime: 3.0.0
|
||||||
@@ -12330,7 +12326,7 @@ snapshots:
|
|||||||
read-pkg: 5.2.0
|
read-pkg: 5.2.0
|
||||||
registry-auth-token: 5.0.2
|
registry-auth-token: 5.0.2
|
||||||
semantic-release: 19.0.5(encoding@0.1.13)
|
semantic-release: 19.0.5(encoding@0.1.13)
|
||||||
semver: 7.3.8
|
semver: 7.6.2
|
||||||
tempy: 1.0.1
|
tempy: 1.0.1
|
||||||
|
|
||||||
'@semantic-release/release-notes-generator@10.0.3(semantic-release@19.0.5(encoding@0.1.13))':
|
'@semantic-release/release-notes-generator@10.0.3(semantic-release@19.0.5(encoding@0.1.13))':
|
||||||
@@ -12674,8 +12670,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/mime@1.3.5': {}
|
'@types/mime@1.3.5': {}
|
||||||
|
|
||||||
'@types/mime@3.0.4': {}
|
|
||||||
|
|
||||||
'@types/minimatch@3.0.5': {}
|
'@types/minimatch@3.0.5': {}
|
||||||
|
|
||||||
'@types/minimist@1.2.5': {}
|
'@types/minimist@1.2.5': {}
|
||||||
@@ -12893,7 +12887,7 @@ snapshots:
|
|||||||
debug: 4.3.5(supports-color@8.1.1)
|
debug: 4.3.5(supports-color@8.1.1)
|
||||||
globby: 11.1.0
|
globby: 11.1.0
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
semver: 7.3.8
|
semver: 7.6.2
|
||||||
tsutils: 3.21.0(typescript@4.9.5)
|
tsutils: 3.21.0(typescript@4.9.5)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 4.9.5
|
typescript: 4.9.5
|
||||||
@@ -13819,13 +13813,13 @@ snapshots:
|
|||||||
ini: 1.3.8
|
ini: 1.3.8
|
||||||
proto-list: 1.2.4
|
proto-list: 1.2.4
|
||||||
|
|
||||||
connect-typeorm@1.1.4(typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))):
|
connect-typeorm@1.1.4(typeorm@0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/debug': 0.0.31
|
'@types/debug': 0.0.31
|
||||||
'@types/express-session': 1.17.6
|
'@types/express-session': 1.17.6
|
||||||
debug: 4.3.5(supports-color@8.1.1)
|
debug: 4.3.5(supports-color@8.1.1)
|
||||||
express-session: 1.18.0
|
express-session: 1.18.0
|
||||||
typeorm: 0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
|
typeorm: 0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -14176,6 +14170,10 @@ snapshots:
|
|||||||
|
|
||||||
date-fns@2.29.3: {}
|
date-fns@2.29.3: {}
|
||||||
|
|
||||||
|
date-fns@2.30.0:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.24.7
|
||||||
|
|
||||||
dateformat@3.0.3: {}
|
dateformat@3.0.3: {}
|
||||||
|
|
||||||
dayjs@1.11.11: {}
|
dayjs@1.11.11: {}
|
||||||
@@ -15730,7 +15728,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
https-proxy-agent@7.0.5:
|
https-proxy-agent@7.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.1
|
agent-base: 7.1.1
|
||||||
debug: 4.3.5(supports-color@8.1.1)
|
debug: 4.3.5(supports-color@8.1.1)
|
||||||
@@ -17140,6 +17138,8 @@ snapshots:
|
|||||||
|
|
||||||
mkdirp@1.0.4: {}
|
mkdirp@1.0.4: {}
|
||||||
|
|
||||||
|
mkdirp@2.1.6: {}
|
||||||
|
|
||||||
modify-values@1.0.1: {}
|
modify-values@1.0.1: {}
|
||||||
|
|
||||||
moment@2.30.1: {}
|
moment@2.30.1: {}
|
||||||
@@ -17269,7 +17269,7 @@ snapshots:
|
|||||||
nopt: 5.0.0
|
nopt: 5.0.0
|
||||||
npmlog: 6.0.2
|
npmlog: 6.0.2
|
||||||
rimraf: 3.0.2
|
rimraf: 3.0.2
|
||||||
semver: 7.3.8
|
semver: 7.6.2
|
||||||
tar: 6.2.1
|
tar: 6.2.1
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -17348,7 +17348,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
hosted-git-info: 4.1.0
|
hosted-git-info: 4.1.0
|
||||||
is-core-module: 2.14.0
|
is-core-module: 2.14.0
|
||||||
semver: 7.3.8
|
semver: 7.6.2
|
||||||
validate-npm-package-license: 3.0.4
|
validate-npm-package-license: 3.0.4
|
||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
@@ -18361,6 +18361,8 @@ snapshots:
|
|||||||
|
|
||||||
reflect-metadata@0.1.13: {}
|
reflect-metadata@0.1.13: {}
|
||||||
|
|
||||||
|
reflect-metadata@0.1.14: {}
|
||||||
|
|
||||||
reflect.getprototypeof@1.0.6:
|
reflect.getprototypeof@1.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.7
|
call-bind: 1.0.7
|
||||||
@@ -19418,23 +19420,23 @@ snapshots:
|
|||||||
|
|
||||||
typedarray@0.0.6: {}
|
typedarray@0.0.6: {}
|
||||||
|
|
||||||
typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)):
|
typeorm@0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sqltools/formatter': 1.2.5
|
'@sqltools/formatter': 1.2.5
|
||||||
app-root-path: 3.1.0
|
app-root-path: 3.1.0
|
||||||
buffer: 6.0.3
|
buffer: 6.0.3
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
cli-highlight: 2.1.11
|
cli-highlight: 2.1.11
|
||||||
date-fns: 2.29.3
|
date-fns: 2.30.0
|
||||||
debug: 4.3.5(supports-color@8.1.1)
|
debug: 4.3.5(supports-color@8.1.1)
|
||||||
dotenv: 16.4.5
|
dotenv: 16.4.5
|
||||||
glob: 7.2.3
|
glob: 8.1.0
|
||||||
js-yaml: 4.1.0
|
js-yaml: 4.1.0
|
||||||
mkdirp: 1.0.4
|
mkdirp: 2.1.6
|
||||||
reflect-metadata: 0.1.13
|
reflect-metadata: 0.1.14
|
||||||
sha.js: 2.4.11
|
sha.js: 2.4.11
|
||||||
tslib: 2.6.3
|
tslib: 2.6.3
|
||||||
uuid: 8.3.2
|
uuid: 9.0.1
|
||||||
xml2js: 0.4.23
|
xml2js: 0.4.23
|
||||||
yargs: 17.7.2
|
yargs: 17.7.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -19473,8 +19475,6 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@5.26.5: {}
|
undici-types@5.26.5: {}
|
||||||
|
|
||||||
undici@6.20.1: {}
|
|
||||||
|
|
||||||
unicode-canonical-property-names-ecmascript@2.0.0: {}
|
unicode-canonical-property-names-ecmascript@2.0.0: {}
|
||||||
|
|
||||||
unicode-emoji-utils@1.2.0:
|
unicode-emoji-utils@1.2.0:
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class ExternalAPI {
|
|||||||
}
|
}
|
||||||
const data = await this.getDataFromResponse(response);
|
const data = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
if (this.cache && ttl !== 0) {
|
if (this.cache) {
|
||||||
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ class ExternalAPI {
|
|||||||
}
|
}
|
||||||
const resData = await this.getDataFromResponse(response);
|
const resData = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
if (this.cache && ttl !== 0) {
|
if (this.cache) {
|
||||||
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ class ExternalAPI {
|
|||||||
}
|
}
|
||||||
const resData = await this.getDataFromResponse(response);
|
const resData = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
if (this.cache && ttl !== 0) {
|
if (this.cache) {
|
||||||
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ class PlexAPI {
|
|||||||
settings.plex.libraries = [];
|
settings.plex.libraries = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
await settings.save();
|
settings.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getLibraryContents(
|
public async getLibraryContents(
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ class RottenTomatoes extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tvshow || !tvshow.rottenTomatoes) {
|
if (!tvshow) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,13 +157,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
|
|
||||||
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<QueueResponse<QueueItemAppendT>>(
|
const data = await this.get<QueueResponse<QueueItemAppendT>>(`/queue`, {
|
||||||
`/queue`,
|
includeEpisode: 'true',
|
||||||
{
|
});
|
||||||
includeEpisode: 'true',
|
|
||||||
},
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
return data.records;
|
return data.records;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -197,24 +193,15 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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.post(
|
await this.post(`/command`, {
|
||||||
`/command`,
|
name: commandName,
|
||||||
{
|
...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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,10 +303,10 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.runCommand('MissingEpisodeSearch', { seriesId });
|
await this.runCommand('SeriesSearch', { seriesId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while executing Sonarr missing episode search.',
|
'Something went wrong while executing Sonarr series search.',
|
||||||
{
|
{
|
||||||
label: 'Sonarr API',
|
label: 'Sonarr API',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ 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 {
|
||||||
@@ -524,7 +523,6 @@ 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(
|
||||||
@@ -572,7 +570,6 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
'vote_count.lte': voteCountLte || '',
|
'vote_count.lte': voteCountLte || '',
|
||||||
with_watch_providers: watchProviders || '',
|
with_watch_providers: watchProviders || '',
|
||||||
watch_region: watchRegion || '',
|
watch_region: watchRegion || '',
|
||||||
with_status: withStatus || '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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,5 +16,4 @@ export enum MediaStatus {
|
|||||||
PROCESSING,
|
PROCESSING,
|
||||||
PARTIALLY_AVAILABLE,
|
PARTIALLY_AVAILABLE,
|
||||||
AVAILABLE,
|
AVAILABLE,
|
||||||
BLACKLISTED,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,3 @@ export enum MediaServerType {
|
|||||||
EMBY,
|
EMBY,
|
||||||
NOT_CONFIGURED,
|
NOT_CONFIGURED,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ServerType {
|
|
||||||
JELLYFIN = 'Jellyfin',
|
|
||||||
EMBY = 'Emby',
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,5 +2,4 @@ export enum UserType {
|
|||||||
PLEX = 1,
|
PLEX = 1,
|
||||||
LOCAL = 2,
|
LOCAL = 2,
|
||||||
JELLYFIN = 3,
|
JELLYFIN = 3,
|
||||||
EMBY = 4,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
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: blacklist,
|
|
||||||
});
|
|
||||||
|
|
||||||
await mediaRepository.save(media);
|
|
||||||
} else {
|
|
||||||
media.blacklist = blacklist;
|
|
||||||
media.status = MediaStatus.BLACKLISTED;
|
|
||||||
media.status4k = MediaStatus.BLACKLISTED;
|
|
||||||
|
|
||||||
await mediaRepository.save(media);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ 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';
|
||||||
@@ -18,7 +17,6 @@ import {
|
|||||||
Entity,
|
Entity,
|
||||||
Index,
|
Index,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
OneToOne,
|
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
@@ -68,7 +66,7 @@ class Media {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
where: { tmdbId: id, mediaType: mediaType },
|
where: { tmdbId: id, mediaType },
|
||||||
relations: { requests: true, issues: true },
|
relations: { requests: true, issues: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,11 +116,6 @@ 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, {
|
|
||||||
eager: true,
|
|
||||||
})
|
|
||||||
public blacklist: Blacklist;
|
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@@ -218,10 +211,9 @@ class Media {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const pageName =
|
const pageName =
|
||||||
getSettings().main.mediaServerType == MediaServerType.EMBY
|
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
|
||||||
? '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
|
||||||
@@ -231,7 +223,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.jellyfinMediaId4k}&context=home&serverId=${serverId}`;
|
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ 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;
|
||||||
@@ -144,16 +143,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,8 @@ 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';
|
||||||
@@ -53,12 +50,6 @@ 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 () => {
|
||||||
@@ -75,11 +66,6 @@ app
|
|||||||
const settings = await 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 &&
|
||||||
@@ -188,7 +174,7 @@ app
|
|||||||
},
|
},
|
||||||
store: new TypeormStore({
|
store: new TypeormStore({
|
||||||
cleanupLimit: 2,
|
cleanupLimit: 2,
|
||||||
ttl: 60 * 60 * 24 * 30,
|
ttl: 1000 * 60 * 60 * 24 * 30,
|
||||||
}).connect(sessionRespository) as Store,
|
}).connect(sessionRespository) as Store,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -216,7 +202,6 @@ app
|
|||||||
|
|
||||||
// Do not set cookies so CDNs can cache them
|
// Do not set cookies so CDNs can cache them
|
||||||
server.use('/imageproxy', clearCookies, imageproxy);
|
server.use('/imageproxy', clearCookies, imageproxy);
|
||||||
server.use('/avatarproxy', clearCookies, avatarproxy);
|
|
||||||
|
|
||||||
server.get('*', (req, res) => handle(req, res));
|
server.get('*', (req, res) => handle(req, res));
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
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[];
|
|
||||||
}
|
|
||||||
@@ -58,7 +58,7 @@ export interface CacheItem {
|
|||||||
|
|
||||||
export interface CacheResponse {
|
export interface CacheResponse {
|
||||||
apiCaches: CacheItem[];
|
apiCaches: CacheItem[];
|
||||||
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
|
imageCache: Record<'tmdb', { size: number; imageCount: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusResponse {
|
export interface StatusResponse {
|
||||||
|
|||||||
@@ -227,9 +227,6 @@ export const startJobs = (): void => {
|
|||||||
});
|
});
|
||||||
// Clean TMDB image cache
|
// Clean TMDB image cache
|
||||||
ImageProxy.clearCache('tmdb');
|
ImageProxy.clearCache('tmdb');
|
||||||
|
|
||||||
// Clean users avatar image cache
|
|
||||||
ImageProxy.clearCache('avatar');
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export interface DownloadingItem {
|
|||||||
timeLeft: string;
|
timeLeft: string;
|
||||||
estimatedCompletionTime: Date;
|
estimatedCompletionTime: Date;
|
||||||
title: string;
|
title: string;
|
||||||
downloadId: string;
|
|
||||||
episode?: EpisodeNumberResult;
|
episode?: EpisodeNumberResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +84,6 @@ 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) => ({
|
||||||
@@ -97,7 +95,6 @@ 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) {
|
||||||
@@ -163,7 +160,6 @@ 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) => ({
|
||||||
@@ -176,7 +172,6 @@ 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) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { RateLimitOptions } from '@server/utils/rateLimit';
|
|||||||
import rateLimit from '@server/utils/rateLimit';
|
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 = {
|
||||||
@@ -12,7 +11,7 @@ type ImageResponse = {
|
|||||||
curRevalidate: number;
|
curRevalidate: number;
|
||||||
isStale: boolean;
|
isStale: boolean;
|
||||||
etag: string;
|
etag: string;
|
||||||
extension: string | null;
|
extension: string;
|
||||||
cacheKey: string;
|
cacheKey: string;
|
||||||
cacheMiss: boolean;
|
cacheMiss: boolean;
|
||||||
};
|
};
|
||||||
@@ -28,45 +27,29 @@ class ImageProxy {
|
|||||||
let deletedImages = 0;
|
let deletedImages = 0;
|
||||||
const cacheDirectory = path.join(baseCacheDirectory, key);
|
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||||
|
|
||||||
try {
|
const files = await promises.readdir(cacheDirectory);
|
||||||
const files = await promises.readdir(cacheDirectory);
|
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const filePath = path.join(cacheDirectory, file);
|
const filePath = path.join(cacheDirectory, file);
|
||||||
const stat = await promises.lstat(filePath);
|
const stat = await promises.lstat(filePath);
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
const imageFiles = await promises.readdir(filePath);
|
const imageFiles = await promises.readdir(filePath);
|
||||||
|
|
||||||
for (const imageFile of imageFiles) {
|
for (const imageFile of imageFiles) {
|
||||||
const [, expireAtSt] = imageFile.split('.');
|
const [, expireAtSt] = imageFile.split('.');
|
||||||
const expireAt = Number(expireAtSt);
|
const expireAt = Number(expireAtSt);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (now > expireAt) {
|
if (now > expireAt) {
|
||||||
await promises.rm(path.join(filePath), {
|
await promises.rm(path.join(filePath, imageFile));
|
||||||
recursive: true,
|
deletedImages += 1;
|
||||||
});
|
|
||||||
deletedImages += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
if (e.code === 'ENOENT') {
|
|
||||||
logger.error('Directory not found', {
|
|
||||||
label: 'Image Cache',
|
|
||||||
message: e.message,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.error('Failed to read directory', {
|
|
||||||
label: 'Image Cache',
|
|
||||||
message: e.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, {
|
logger.info(`Cleared ${deletedImages} stale image(s) from cache`, {
|
||||||
label: 'Image Cache',
|
label: 'Image Cache',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -86,56 +69,39 @@ class ImageProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async getDirectorySize(dir: string): Promise<number> {
|
private static async getDirectorySize(dir: string): Promise<number> {
|
||||||
try {
|
const files = await promises.readdir(dir, {
|
||||||
const files = await promises.readdir(dir, {
|
withFileTypes: true,
|
||||||
withFileTypes: true,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const paths = files.map(async (file) => {
|
const paths = files.map(async (file) => {
|
||||||
const path = join(dir, file.name);
|
const path = join(dir, file.name);
|
||||||
|
|
||||||
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
|
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
|
||||||
|
|
||||||
if (file.isFile()) {
|
if (file.isFile()) {
|
||||||
const { size } = await promises.stat(path);
|
const { size } = await promises.stat(path);
|
||||||
|
|
||||||
return size;
|
return size;
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (await Promise.all(paths))
|
|
||||||
.flat(Infinity)
|
|
||||||
.reduce((i, size) => i + size, 0);
|
|
||||||
} catch (e) {
|
|
||||||
if (e.code === 'ENOENT') {
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (await Promise.all(paths))
|
||||||
|
.flat(Infinity)
|
||||||
|
.reduce((i, size) => i + size, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async getImageCount(dir: string) {
|
private static async getImageCount(dir: string) {
|
||||||
try {
|
const files = await promises.readdir(dir);
|
||||||
const files = await promises.readdir(dir);
|
|
||||||
|
|
||||||
return files.length;
|
return files.length;
|
||||||
} catch (e) {
|
|
||||||
if (e.code === 'ENOENT') {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetch: typeof fetch;
|
private fetch: typeof fetch;
|
||||||
private cacheVersion;
|
private cacheVersion;
|
||||||
private key;
|
private key;
|
||||||
private baseUrl;
|
private baseUrl;
|
||||||
private headers: HeadersInit | null = null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
key: string,
|
key: string,
|
||||||
@@ -143,7 +109,6 @@ class ImageProxy {
|
|||||||
options: {
|
options: {
|
||||||
cacheVersion?: number;
|
cacheVersion?: number;
|
||||||
rateLimitOptions?: RateLimitOptions;
|
rateLimitOptions?: RateLimitOptions;
|
||||||
headers?: HeadersInit;
|
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
this.cacheVersion = options.cacheVersion ?? 1;
|
this.cacheVersion = options.cacheVersion ?? 1;
|
||||||
@@ -157,13 +122,9 @@ class ImageProxy {
|
|||||||
} else {
|
} else {
|
||||||
this.fetch = fetch;
|
this.fetch = fetch;
|
||||||
}
|
}
|
||||||
this.headers = options.headers || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getImage(
|
public async getImage(path: string): Promise<ImageResponse> {
|
||||||
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);
|
||||||
@@ -172,11 +133,7 @@ class ImageProxy {
|
|||||||
const newImage = await this.set(path, cacheKey);
|
const newImage = await this.set(path, cacheKey);
|
||||||
|
|
||||||
if (!newImage) {
|
if (!newImage) {
|
||||||
if (fallbackPath) {
|
throw new Error('Failed to load image');
|
||||||
return await this.getImage(fallbackPath);
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to load image');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return newImage;
|
return newImage;
|
||||||
@@ -190,27 +147,6 @@ class ImageProxy {
|
|||||||
return imageResponse;
|
return imageResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async clearCachedImage(path: string) {
|
|
||||||
// find cacheKey
|
|
||||||
const cacheKey = this.getCacheKey(path);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
|
||||||
const files = await promises.readdir(directory);
|
|
||||||
|
|
||||||
await promises.rm(directory, { recursive: true });
|
|
||||||
|
|
||||||
logger.info(`Cleared ${files[0]} 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);
|
||||||
@@ -251,30 +187,16 @@ class ImageProxy {
|
|||||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||||
const href =
|
const href =
|
||||||
this.baseUrl +
|
this.baseUrl +
|
||||||
(this.baseUrl.length > 0
|
(this.baseUrl.endsWith('/') ? '' : '/') +
|
||||||
? this.baseUrl.endsWith('/')
|
|
||||||
? ''
|
|
||||||
: '/'
|
|
||||||
: '') +
|
|
||||||
(path.startsWith('/') ? path.slice(1) : path);
|
(path.startsWith('/') ? path.slice(1) : path);
|
||||||
const response = await this.fetch(href, {
|
const response = await this.fetch(href);
|
||||||
headers: this.headers || undefined,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
const extension = mime.getExtension(
|
const extension = path.split('.').pop() ?? '';
|
||||||
response.headers.get('content-type') ?? ''
|
const maxAge = Number(
|
||||||
);
|
|
||||||
|
|
||||||
let maxAge = Number(
|
|
||||||
(response.headers.get('cache-control') ?? '0').split('=')[1]
|
(response.headers.get('cache-control') ?? '0').split('=')[1]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!maxAge) maxAge = 86400;
|
|
||||||
const expireAt = Date.now() + maxAge * 1000;
|
const expireAt = Date.now() + maxAge * 1000;
|
||||||
const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
|
const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
|
||||||
|
|
||||||
@@ -310,7 +232,7 @@ class ImageProxy {
|
|||||||
|
|
||||||
private async writeToCacheDir(
|
private async writeToCacheDir(
|
||||||
dir: string,
|
dir: string,
|
||||||
extension: string | null,
|
extension: string,
|
||||||
maxAge: number,
|
maxAge: number,
|
||||||
expireAt: number,
|
expireAt: number,
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ 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,10 +567,7 @@ class JellyfinScanner {
|
|||||||
public async run(): Promise<void> {
|
public async run(): Promise<void> {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
if (
|
if (settings.main.mediaServerType != MediaServerType.JELLYFIN) {
|
||||||
settings.main.mediaServerType != MediaServerType.JELLYFIN &&
|
|
||||||
settings.main.mediaServerType != MediaServerType.EMBY
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class PlexScanner
|
|||||||
});
|
});
|
||||||
|
|
||||||
settings.plex.libraries = newLibraries;
|
settings.plex.libraries = newLibraries;
|
||||||
await settings.save();
|
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/promises';
|
import fs from 'fs';
|
||||||
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';
|
||||||
@@ -99,17 +99,6 @@ 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;
|
||||||
@@ -130,7 +119,6 @@ export interface MainSettings {
|
|||||||
mediaServerType: number;
|
mediaServerType: number;
|
||||||
partialRequestsEnabled: boolean;
|
partialRequestsEnabled: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
proxy: ProxySettings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublicSettings {
|
interface PublicSettings {
|
||||||
@@ -337,16 +325,6 @@ 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: '',
|
||||||
@@ -501,6 +479,10 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,28 +584,42 @@ 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 async regenerateApiKey(): Promise<MainSettings> {
|
public regenerateApiKey(): MainSettings {
|
||||||
this.main.apiKey = this.generateApiKey();
|
this.main.apiKey = this.generateApiKey();
|
||||||
await this.save();
|
this.save();
|
||||||
return this.main;
|
return this.main;
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateApiKey(): string {
|
private generateApiKey(): string {
|
||||||
if (process.env.API_KEY) {
|
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
|
||||||
return process.env.API_KEY;
|
}
|
||||||
} else {
|
|
||||||
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
|
private generateVapidKeys(force = false): void {
|
||||||
|
if (!this.data.vapidPublic || !this.data.vapidPrivate || force) {
|
||||||
|
const vapidKeys = webpush.generateVAPIDKeys();
|
||||||
|
this.data.vapidPrivate = vapidKeys.privateKey;
|
||||||
|
this.data.vapidPublic = vapidKeys.publicKey;
|
||||||
|
this.save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,51 +637,24 @@ class Settings {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
let data;
|
if (!fs.existsSync(SETTINGS_PATH)) {
|
||||||
try {
|
this.save();
|
||||||
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);
|
||||||
const migratedData = await runMigrations(parsedJson, SETTINGS_PATH);
|
this.data = await runMigrations(parsedJson);
|
||||||
this.data = merge(this.data, migratedData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// generate keys and ids if it's missing
|
this.data = merge(this.data, parsedJson);
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async save(): Promise<void> {
|
public save(): void {
|
||||||
await fs.writeFile(
|
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(this.data, undefined, ' '));
|
||||||
SETTINGS_PATH,
|
|
||||||
JSON.stringify(this.data, undefined, ' ')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { AllSettings } from '@server/lib/settings';
|
import type { AllSettings } from '@server/lib/settings';
|
||||||
|
|
||||||
const migrateHostname = (settings: any): AllSettings => {
|
const migrateHostname = (settings: any): AllSettings => {
|
||||||
if (settings.jellyfin?.hostname) {
|
const oldJellyfinSettings = settings.jellyfin;
|
||||||
const { hostname } = settings.jellyfin;
|
if (oldJellyfinSettings && oldJellyfinSettings.hostname) {
|
||||||
|
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 settings.jellyfin.hostname;
|
delete oldJellyfinSettings.hostname;
|
||||||
if (urlMatch) {
|
if (urlMatch) {
|
||||||
const [, ip, , port, urlBase] = urlMatch;
|
const [, ip, , port, urlBase] = urlMatch;
|
||||||
settings.jellyfin = {
|
settings.jellyfin = {
|
||||||
@@ -20,7 +21,9 @@ const migrateHostname = (settings: any): AllSettings => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (settings.jellyfin && settings.jellyfin.hostname) {
|
||||||
|
delete settings.jellyfin.hostname;
|
||||||
|
}
|
||||||
return settings;
|
return settings;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
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,95 +1,30 @@
|
|||||||
/* eslint-disable no-console */
|
|
||||||
import type { AllSettings } from '@server/lib/settings';
|
import type { AllSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const migrationsDir = path.join(__dirname, 'migrations');
|
const migrationsDir = path.join(__dirname, 'migrations');
|
||||||
|
|
||||||
export const runMigrations = async (
|
export const runMigrations = async (
|
||||||
settings: AllSettings,
|
settings: AllSettings
|
||||||
SETTINGS_PATH: string
|
|
||||||
): Promise<AllSettings> => {
|
): Promise<AllSettings> => {
|
||||||
|
const migrations = fs
|
||||||
|
.readdirSync(migrationsDir)
|
||||||
|
.filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
.map((file) => require(path.join(migrationsDir, file)).default);
|
||||||
|
|
||||||
let migrated = settings;
|
let migrated = settings;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 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) {
|
for (const migration of migrations) {
|
||||||
try {
|
migrated = await migration(migrated);
|
||||||
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) {
|
|
||||||
logger.error(`Error while running migration '${migration}'`, {
|
|
||||||
label: 'Settings Migrator',
|
|
||||||
});
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while running settings migrations: ${e.message}`,
|
`Something went wrong while running settings migrations: ${e.message}`,
|
||||||
{ label: 'Settings Migrator' }
|
{ label: 'Settings Migrator' }
|
||||||
);
|
);
|
||||||
// we stop jellyseerr if the migration failed
|
|
||||||
console.log(
|
|
||||||
'===================================================================='
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
' SOMETHING WENT WRONG WHILE RUNNING SETTINGS MIGRATIONS '
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
' Please check that your configuration folder is properly set up '
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
'===================================================================='
|
|
||||||
);
|
|
||||||
process.exit();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return migrated;
|
return migrated;
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
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"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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, ServerType } from '@server/constants/server';
|
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 { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
@@ -14,6 +14,7 @@ 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();
|
||||||
@@ -87,7 +88,7 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
settings.main.mediaServerType = MediaServerType.PLEX;
|
settings.main.mediaServerType = MediaServerType.PLEX;
|
||||||
await settings.save();
|
settings.save();
|
||||||
startJobs();
|
startJobs();
|
||||||
|
|
||||||
await userRepository.save(user);
|
await userRepository.save(user);
|
||||||
@@ -226,20 +227,15 @@ 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 && Emby is not already configured
|
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured
|
||||||
if (
|
if (
|
||||||
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||||
settings.main.mediaServerType !== MediaServerType.EMBY &&
|
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
|
||||||
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
|
||||||
@@ -260,6 +256,8 @@ 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 },
|
||||||
@@ -275,7 +273,11 @@ 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;
|
||||||
@@ -315,39 +317,22 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// User doesn't exist, and there are no users in the database, we'll create the user
|
// User doesn't exist, and there are no users in the database, we'll create the user
|
||||||
// with admin permissions
|
// with admin permission
|
||||||
switch (body.serverType) {
|
settings.main.mediaServerType = MediaServerType.JELLYFIN;
|
||||||
case MediaServerType.EMBY:
|
user = new User({
|
||||||
settings.main.mediaServerType = MediaServerType.EMBY;
|
email: body.email || account.User.Name,
|
||||||
user = new User({
|
jellyfinUsername: account.User.Name,
|
||||||
email: body.email || account.User.Name,
|
jellyfinUserId: account.User.Id,
|
||||||
jellyfinUsername: account.User.Name,
|
jellyfinDeviceId: deviceId,
|
||||||
jellyfinUserId: account.User.Id,
|
permissions: Permission.ADMIN,
|
||||||
jellyfinDeviceId: deviceId,
|
avatar: account.User.PrimaryImageTag
|
||||||
jellyfinAuthToken: account.AccessToken,
|
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
||||||
permissions: Permission.ADMIN,
|
: gravatarUrl(body.email || account.User.Name, {
|
||||||
avatar: `/avatarproxy/${account.User.Id}`,
|
default: 'mm',
|
||||||
userType: UserType.EMBY,
|
size: 200,
|
||||||
});
|
}),
|
||||||
|
userType: UserType.JELLYFIN,
|
||||||
break;
|
});
|
||||||
case MediaServerType.JELLYFIN:
|
|
||||||
settings.main.mediaServerType = MediaServerType.JELLYFIN;
|
|
||||||
user = new User({
|
|
||||||
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: UserType.JELLYFIN,
|
|
||||||
});
|
|
||||||
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error('select_server_type');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create an API key on Jellyfin from this admin user
|
// Create an API key on Jellyfin from this admin user
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
@@ -366,7 +351,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
settings.jellyfin.urlBase = body.urlBase ?? '';
|
settings.jellyfin.urlBase = body.urlBase ?? '';
|
||||||
settings.jellyfin.useSsl = body.useSsl ?? false;
|
settings.jellyfin.useSsl = body.useSsl ?? false;
|
||||||
settings.jellyfin.apiKey = apiKey;
|
settings.jellyfin.apiKey = apiKey;
|
||||||
await settings.save();
|
settings.save();
|
||||||
startJobs();
|
startJobs();
|
||||||
|
|
||||||
await userRepository.save(user);
|
await userRepository.save(user);
|
||||||
@@ -376,12 +361,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
logger.info(
|
logger.info(
|
||||||
`Found matching ${
|
`Found matching ${
|
||||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||||
? ServerType.JELLYFIN
|
? 'Jellyfin'
|
||||||
: ServerType.EMBY
|
: 'Emby'
|
||||||
} user; updating user with ${
|
} user; updating user with ${
|
||||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||||
? ServerType.JELLYFIN
|
? 'Jellyfin'
|
||||||
: ServerType.EMBY
|
: 'Emby'
|
||||||
}`,
|
}`,
|
||||||
{
|
{
|
||||||
label: 'API',
|
label: 'API',
|
||||||
@@ -389,13 +374,27 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
jellyfinUsername: account.User.Name,
|
jellyfinUsername: account.User.Name,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
user.avatar = `/avatarproxy/${account.User.Id}`;
|
// 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 || account.User.Name, {
|
||||||
|
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(
|
||||||
@@ -427,13 +426,14 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
jellyfinUserId: account.User.Id,
|
jellyfinUserId: account.User.Id,
|
||||||
jellyfinDeviceId: deviceId,
|
jellyfinDeviceId: deviceId,
|
||||||
permissions: settings.main.defaultPermissions,
|
permissions: settings.main.defaultPermissions,
|
||||||
avatar: `/avatarproxy/${account.User.Id}`,
|
avatar: account.User.PrimaryImageTag
|
||||||
userType:
|
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
||||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
: gravatarUrl(body.email || account.User.Name, {
|
||||||
? UserType.JELLYFIN
|
default: 'mm',
|
||||||
: UserType.EMBY,
|
size: 200,
|
||||||
|
}),
|
||||||
|
userType: UserType.JELLYFIN,
|
||||||
});
|
});
|
||||||
|
|
||||||
//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) {
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import { MediaType } from '@server/constants/media';
|
|
||||||
import { getRepository } from '@server/datasource';
|
|
||||||
import { Blacklist } from '@server/entity/Blacklist';
|
|
||||||
import Media from '@server/entity/Media';
|
|
||||||
import { NotFoundError } from '@server/entity/Watchlist';
|
|
||||||
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
|
|
||||||
import { Permission } from '@server/lib/permissions';
|
|
||||||
import logger from '@server/logger';
|
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
|
||||||
import { Router } from 'express';
|
|
||||||
import rateLimit from 'express-rate-limit';
|
|
||||||
import { QueryFailedError } from 'typeorm';
|
|
||||||
import { 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',
|
|
||||||
}),
|
|
||||||
rateLimit({ windowMs: 60 * 1000, max: 50 }),
|
|
||||||
async (req, res, next) => {
|
|
||||||
const pageSize = req.query.take ? Number(req.query.take) : 25;
|
|
||||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
|
||||||
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.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 NotFoundError) {
|
|
||||||
return next({
|
|
||||||
status: 401,
|
|
||||||
message: e.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return next({ status: 500, message: e.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default blacklistRoutes;
|
|
||||||
@@ -71,7 +71,6 @@ 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>;
|
||||||
@@ -386,7 +385,6 @@ 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(
|
||||||
|
|||||||
@@ -17,17 +17,12 @@ import { mapProductionCompany } from '@server/models/Movie';
|
|||||||
import { mapNetwork } from '@server/models/Tv';
|
import { mapNetwork } from '@server/models/Tv';
|
||||||
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 {
|
import { appDataPath, appDataStatus } from '@server/utils/appDataVolume';
|
||||||
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';
|
||||||
@@ -97,7 +92,6 @@ 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(),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,7 +144,6 @@ 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);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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,
|
||||||
@@ -244,8 +243,6 @@ 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,13 +123,9 @@ 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 =
|
const languageProfiles = await sonarr.getLanguageProfiles();
|
||||||
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,6 +32,7 @@ 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';
|
||||||
@@ -69,19 +70,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', async (req, res) => {
|
settingsRoutes.post('/main', (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.main = merge(settings.main, req.body);
|
settings.main = merge(settings.main, req.body);
|
||||||
await settings.save();
|
settings.save();
|
||||||
|
|
||||||
return res.status(200).json(settings.main);
|
return res.status(200).json(settings.main);
|
||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.post('/main/regenerate', async (req, res, next) => {
|
settingsRoutes.post('/main/regenerate', (req, res, next) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
const main = await settings.regenerateApiKey();
|
const main = 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.' });
|
||||||
@@ -118,7 +119,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;
|
||||||
|
|
||||||
await settings.save();
|
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',
|
||||||
@@ -231,7 +232,7 @@ settingsRoutes.get('/plex/library', async (req, res) => {
|
|||||||
...library,
|
...library,
|
||||||
enabled: enabledLibraries.includes(library.id),
|
enabled: enabledLibraries.includes(library.id),
|
||||||
}));
|
}));
|
||||||
await settings.save();
|
settings.save();
|
||||||
return res.status(200).json(settings.plex.libraries);
|
return res.status(200).json(settings.plex.libraries);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,7 +283,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;
|
||||||
await settings.save();
|
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', {
|
||||||
@@ -370,12 +371,17 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
|||||||
...library,
|
...library,
|
||||||
enabled: enabledLibraries.includes(library.id),
|
enabled: enabledLibraries.includes(library.id),
|
||||||
}));
|
}));
|
||||||
await settings.save();
|
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 settings = getSettings();
|
const settings = getSettings();
|
||||||
|
const { externalHostname } = settings.jellyfin;
|
||||||
|
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({
|
||||||
@@ -394,7 +400,9 @@ 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: `/avatarproxy/${user.Id}`,
|
thumb: user.PrimaryImageTag
|
||||||
|
? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
|
||||||
|
: gravatarUrl(user.Name, { default: 'mm', size: 200 }),
|
||||||
email: user.Name,
|
email: user.Name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -434,7 +442,7 @@ settingsRoutes.post('/tautulli', async (req, res, next) => {
|
|||||||
throw new Error('Tautulli version not supported');
|
throw new Error('Tautulli version not supported');
|
||||||
}
|
}
|
||||||
|
|
||||||
await settings.save();
|
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',
|
||||||
@@ -695,7 +703,7 @@ settingsRoutes.post<{ jobId: JobId }>(
|
|||||||
|
|
||||||
settingsRoutes.post<{ jobId: JobId }>(
|
settingsRoutes.post<{ jobId: JobId }>(
|
||||||
'/jobs/:jobId/schedule',
|
'/jobs/:jobId/schedule',
|
||||||
async (req, res, next) => {
|
(req, res, next) => {
|
||||||
const scheduledJob = scheduledJobs.find(
|
const scheduledJob = scheduledJobs.find(
|
||||||
(job) => job.id === req.params.jobId
|
(job) => job.id === req.params.jobId
|
||||||
);
|
);
|
||||||
@@ -709,7 +717,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;
|
||||||
await settings.save();
|
settings.save();
|
||||||
|
|
||||||
scheduledJob.cronSchedule = req.body.schedule;
|
scheduledJob.cronSchedule = req.body.schedule;
|
||||||
|
|
||||||
@@ -738,13 +746,11 @@ settingsRoutes.get('/cache', async (_req, res) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
|
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
|
||||||
const avatarImageCache = await ImageProxy.getImageStats('avatar');
|
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
apiCaches,
|
apiCaches,
|
||||||
imageCache: {
|
imageCache: {
|
||||||
tmdb: tmdbImageCache,
|
tmdb: tmdbImageCache,
|
||||||
avatar: avatarImageCache,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -766,11 +772,11 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
|
|||||||
settingsRoutes.post(
|
settingsRoutes.post(
|
||||||
'/initialize',
|
'/initialize',
|
||||||
isAuthenticated(Permission.ADMIN),
|
isAuthenticated(Permission.ADMIN),
|
||||||
async (_req, res) => {
|
(_req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.public.initialized = true;
|
settings.public.initialized = true;
|
||||||
await settings.save();
|
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', async (req, res) => {
|
notificationRoutes.post('/discord', (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.discord = req.body;
|
settings.notifications.agents.discord = req.body;
|
||||||
await settings.save();
|
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', async (req, res) => {
|
notificationRoutes.post('/slack', (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.slack = req.body;
|
settings.notifications.agents.slack = req.body;
|
||||||
await settings.save();
|
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', async (req, res) => {
|
notificationRoutes.post('/telegram', (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.telegram = req.body;
|
settings.notifications.agents.telegram = req.body;
|
||||||
await settings.save();
|
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', async (req, res) => {
|
notificationRoutes.post('/pushbullet', (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.pushbullet = req.body;
|
settings.notifications.agents.pushbullet = req.body;
|
||||||
await settings.save();
|
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', async (req, res) => {
|
notificationRoutes.post('/pushover', (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.pushover = req.body;
|
settings.notifications.agents.pushover = req.body;
|
||||||
await settings.save();
|
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', async (req, res) => {
|
notificationRoutes.post('/email', (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.email = req.body;
|
settings.notifications.agents.email = req.body;
|
||||||
await settings.save();
|
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', async (req, res) => {
|
notificationRoutes.post('/webpush', (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.webpush = req.body;
|
settings.notifications.agents.webpush = req.body;
|
||||||
await settings.save();
|
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', async (req, res, next) => {
|
notificationRoutes.post('/webhook', (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', async (req, res, next) => {
|
|||||||
authHeader: req.body.options.authHeader,
|
authHeader: req.body.options.authHeader,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await settings.save();
|
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', async (req, res) => {
|
notificationRoutes.post('/lunasea', (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.lunasea = req.body;
|
settings.notifications.agents.lunasea = req.body;
|
||||||
await settings.save();
|
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', async (req, res) => {
|
notificationRoutes.post('/gotify', (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.notifications.agents.gotify = req.body;
|
settings.notifications.agents.gotify = req.body;
|
||||||
await settings.save();
|
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('/', async (req, res) => {
|
radarrRoutes.post('/', (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('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
settings.radarr = [...settings.radarr, newRadarr];
|
settings.radarr = [...settings.radarr, newRadarr];
|
||||||
await settings.save();
|
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',
|
||||||
async (req, res, next) => {
|
(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;
|
||||||
await settings.save();
|
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', async (req, res, next) => {
|
radarrRoutes.delete<{ id: string }>('/:id', (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', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removed = settings.radarr.splice(radarrIndex, 1);
|
const removed = settings.radarr.splice(radarrIndex, 1);
|
||||||
await settings.save();
|
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('/', async (req, res) => {
|
sonarrRoutes.post('/', (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('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
settings.sonarr = [...settings.sonarr, newSonarr];
|
settings.sonarr = [...settings.sonarr, newSonarr];
|
||||||
await settings.save();
|
settings.save();
|
||||||
|
|
||||||
return res.status(201).json(newSonarr);
|
return res.status(201).json(newSonarr);
|
||||||
});
|
});
|
||||||
@@ -43,14 +43,13 @@ sonarrRoutes.post('/test', async (req, res, next) => {
|
|||||||
url: SonarrAPI.buildUrl(req.body, '/api/v3'),
|
url: SonarrAPI.buildUrl(req.body, '/api/v3'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const systemStatus = await sonarr.getSystemStatus();
|
const urlBase = await sonarr
|
||||||
const sonarrMajorVersion = Number(systemStatus.version.split('.')[0]);
|
.getSystemStatus()
|
||||||
|
.then((value) => value.urlBase)
|
||||||
const urlBase = systemStatus.urlBase;
|
.catch(() => req.body.baseUrl);
|
||||||
const profiles = await sonarr.getProfiles();
|
const profiles = await sonarr.getProfiles();
|
||||||
const folders = await sonarr.getRootFolders();
|
const folders = await sonarr.getRootFolders();
|
||||||
const languageProfiles =
|
const languageProfiles = await sonarr.getLanguageProfiles();
|
||||||
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({
|
||||||
@@ -73,7 +72,7 @@ sonarrRoutes.post('/test', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
sonarrRoutes.put<{ id: string }>('/:id', async (req, res) => {
|
sonarrRoutes.put<{ id: string }>('/:id', (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
const sonarrIndex = settings.sonarr.findIndex(
|
const sonarrIndex = settings.sonarr.findIndex(
|
||||||
@@ -101,12 +100,12 @@ sonarrRoutes.put<{ id: string }>('/:id', async (req, res) => {
|
|||||||
...req.body,
|
...req.body,
|
||||||
id: Number(req.params.id),
|
id: Number(req.params.id),
|
||||||
} as SonarrSettings;
|
} as SonarrSettings;
|
||||||
await settings.save();
|
settings.save();
|
||||||
|
|
||||||
return res.status(200).json(settings.sonarr[sonarrIndex]);
|
return res.status(200).json(settings.sonarr[sonarrIndex]);
|
||||||
});
|
});
|
||||||
|
|
||||||
sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => {
|
sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
const sonarrIndex = settings.sonarr.findIndex(
|
const sonarrIndex = settings.sonarr.findIndex(
|
||||||
@@ -120,7 +119,7 @@ sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removed = settings.sonarr.splice(sonarrIndex, 1);
|
const removed = settings.sonarr.splice(sonarrIndex, 1);
|
||||||
await settings.save();
|
settings.save();
|
||||||
|
|
||||||
return res.status(200).json(removed[0]);
|
return res.status(200).json(removed[0]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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';
|
||||||
@@ -516,6 +515,12 @@ router.post(
|
|||||||
|
|
||||||
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
||||||
const createdUsers: User[] = [];
|
const createdUsers: User[] = [];
|
||||||
|
const { externalHostname } = getSettings().jellyfin;
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -539,11 +544,13 @@ router.post(
|
|||||||
).toString('base64'),
|
).toString('base64'),
|
||||||
email: jellyfinUser?.Name,
|
email: jellyfinUser?.Name,
|
||||||
permissions: settings.main.defaultPermissions,
|
permissions: settings.main.defaultPermissions,
|
||||||
avatar: `/avatarproxy/${jellyfinUser?.Id}`,
|
avatar: jellyfinUser?.PrimaryImageTag
|
||||||
userType:
|
? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
|
||||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
: gravatarUrl(jellyfinUser?.Name ?? '', {
|
||||||
? UserType.JELLYFIN
|
default: 'mm',
|
||||||
: UserType.EMBY,
|
size: 200,
|
||||||
|
}),
|
||||||
|
userType: UserType.JELLYFIN,
|
||||||
});
|
});
|
||||||
|
|
||||||
await userRepository.save(newUser);
|
await userRepository.save(newUser);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ApiErrorCode } from '@server/constants/error';
|
|
||||||
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';
|
||||||
@@ -10,7 +9,6 @@ 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 '.';
|
||||||
|
|
||||||
@@ -100,18 +98,10 @@ userSettingsRoutes.post<
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.username = req.body.username;
|
user.username = req.body.username;
|
||||||
const oldEmail = user.email;
|
|
||||||
if (user.jellyfinUsername) {
|
if (user.jellyfinUsername) {
|
||||||
user.email = req.body.email || user.jellyfinUsername || user.email;
|
user.email = req.body.email || user.jellyfinUsername || user.email;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
||||||
!user.hasPermission(Permission.MANAGE_USERS) &&
|
!user.hasPermission(Permission.MANAGE_USERS) &&
|
||||||
@@ -155,14 +145,7 @@ userSettingsRoutes.post<
|
|||||||
email: savedUser.email,
|
email: savedUser.email,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.errorCode) {
|
next({ status: 500, message: e.message });
|
||||||
return next({
|
|
||||||
status: e.statusCode,
|
|
||||||
message: e.errorCode,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return next({ status: 500, message: e.message });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { accessSync, existsSync } from 'fs';
|
import { 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,12 +14,3 @@ 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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -13,8 +13,7 @@ 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
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -1,131 +1,46 @@
|
|||||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
y="0px" width="100%" viewBox="0 0 617 188" enable-background="new 0 0 617 188" xml:space="preserve">
|
<svg
|
||||||
<path fill="#52B54B" opacity="1.000000" stroke="none" d="
|
version="1.1"
|
||||||
M89.583336,1.000000
|
id="svg2"
|
||||||
C90.189529,1.685005 90.166168,2.574803 90.599510,3.025271
|
viewBox="0 0 712.60077 712.5481"
|
||||||
C103.718315,16.662701 116.882103,30.256845 129.948212,43.764053
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
C130.577850,43.523941 130.916519,43.491173 131.111343,43.306595
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
C138.657471,36.157455 138.655273,36.156090 146.005478,43.505203
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
C159.538589,57.036308 173.016449,70.623535 186.654617,84.047913
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
C189.264145,86.616562 189.414017,88.253456 186.716782,90.895164
|
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
C174.709808,102.655037 162.893280,114.609337 151.008514,126.493958
|
<defs
|
||||||
C146.073502,131.428925 146.076691,131.427155 151.017944,136.523712
|
id="defs4" />
|
||||||
C151.698944,137.226120 152.340485,137.966812 153.259171,138.973434
|
<metadata
|
||||||
C151.947098,140.380035 150.766312,141.712204 149.516266,142.975861
|
id="metadata7">
|
||||||
C134.544815,158.110641 119.563087,173.235260 104.792023,188.681274
|
<rdf:RDF>
|
||||||
C103.611107,189.000000 102.222221,189.000000 100.624634,188.681274
|
<cc:Work
|
||||||
C86.361732,174.796494 72.307518,161.230438 57.702755,147.132965
|
rdf:about="">
|
||||||
C56.157101,149.136856 54.135899,151.757263 51.994804,154.533112
|
<dc:format>image/svg+xml</dc:format>
|
||||||
C35.932781,138.457108 20.569420,123.048477 5.141897,107.704361
|
<dc:type
|
||||||
C3.997114,106.565773 2.391420,105.890610 1.000000,105.000000
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
C1.000000,103.611107 1.000000,102.222221 1.318741,100.624641
|
</cc:Work>
|
||||||
C15.203506,86.361694 28.769531,72.307434 42.867004,57.702602
|
</rdf:RDF>
|
||||||
C40.863205,56.156994 38.242813,54.135792 35.425343,51.962570
|
</metadata>
|
||||||
C51.518696,35.908516 66.939468,20.557360 82.295547,5.141749
|
<rect
|
||||||
C83.434830,3.998048 84.109390,2.391417 85.000000,0.999999
|
style="opacity:0;fill:#ffffff;stroke-width:4.12543"
|
||||||
C86.388893,1.000000 87.777779,1.000000 89.583336,1.000000
|
id="rect249"
|
||||||
M73.196465,79.500702
|
width="712.60077"
|
||||||
C73.196465,96.254150 73.196465,113.007599 73.196465,130.872055
|
height="712.5481"
|
||||||
C94.273178,118.764557 114.417175,107.192863 135.221664,95.241745
|
x="-0.00071160076"
|
||||||
C114.247169,83.251732 94.091187,71.729622 73.196594,59.785294
|
y="2.0223413e-11" />
|
||||||
C73.196594,66.631348 73.196594,72.566254 73.196465,79.500702
|
<rect
|
||||||
z" />
|
style="fill:#ffffff"
|
||||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none" d="
|
id="rect289"
|
||||||
M618.000000,60.571537
|
width="230.18982"
|
||||||
C617.004395,62.042580 615.613281,62.912964 615.073181,64.153824
|
height="229.82355"
|
||||||
C608.143372,80.073746 601.328613,96.043816 594.498169,112.006920
|
x="241.20476"
|
||||||
C586.973572,129.592300 579.343018,147.133865 571.999390,164.794601
|
y="241.36227" />
|
||||||
C568.632385,172.892075 568.893372,173.002594 560.133972,172.999832
|
<g
|
||||||
C555.470825,172.998367 550.807617,172.994385 546.144592,172.969360
|
id="layer1"
|
||||||
C545.841980,172.967712 545.540466,172.775543 544.836609,172.534256
|
transform="matrix(0.70249853,0,0,0.70249853,88.770447,96.84571)">
|
||||||
C548.592896,163.531219 551.714905,154.222061 556.286133,145.689255
|
<path
|
||||||
C559.733765,139.253830 559.138794,134.062668 556.454224,127.695969
|
id="path3427"
|
||||||
C546.360352,103.757523 536.803345,79.592712 526.837830,55.000847
|
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"
|
||||||
C534.817078,55.000847 542.437622,54.725182 550.003540,55.244331
|
style="fill:#52b54b;fill-opacity:1;stroke:none" />
|
||||||
C551.436218,55.342628 553.169678,58.412052 553.885010,60.423309
|
</g>
|
||||||
C558.720520,74.018005 563.307556,87.700912 568.003784,101.345413
|
</svg>
|
||||||
C569.107483,104.551987 570.321045,107.720764 571.976196,112.255157
|
|
||||||
C573.889587,107.365631 575.415283,103.375916 577.007935,99.413109
|
|
||||||
C582.693298,85.266724 588.344238,71.105591 594.218018,57.037624
|
|
||||||
C594.650513,56.001743 596.734497,55.132927 598.079773,55.089733
|
|
||||||
C604.401855,54.886726 610.734131,54.999401 617.531372,54.999699
|
|
||||||
C618.000000,56.714359 618.000000,58.428715 618.000000,60.571537
|
|
||||||
z" />
|
|
||||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none" d="
|
|
||||||
M430.000122,99.002235
|
|
||||||
C430.000122,112.477097 430.000122,125.452438 430.000122,138.713440
|
|
||||||
C423.048126,138.713440 416.308685,138.713440 408.999878,138.713440
|
|
||||||
C408.999878,129.350739 409.120758,119.916939 408.962219,110.487823
|
|
||||||
C408.832153,102.753624 409.088898,94.909142 407.866791,87.324188
|
|
||||||
C406.440887,78.474220 401.302399,74.201607 394.304291,74.000290
|
|
||||||
C387.617249,73.807938 380.317963,79.297188 378.047363,86.438652
|
|
||||||
C377.420715,88.409592 377.055725,90.550858 377.044647,92.616508
|
|
||||||
C376.962494,107.913475 377.000122,123.211082 377.000122,138.753479
|
|
||||||
C369.630646,138.753479 362.559692,138.753479 354.999878,138.753479
|
|
||||||
C354.999878,123.256836 355.044769,107.816956 354.977661,92.377571
|
|
||||||
C354.951050,86.251518 352.748199,80.799278 347.911346,77.066116
|
|
||||||
C339.239685,70.373154 327.811401,74.635170 324.084412,84.471092
|
|
||||||
C322.793915,87.876816 322.147491,91.713402 322.090881,95.366882
|
|
||||||
C321.868958,109.685005 322.000122,124.008591 322.000122,138.665009
|
|
||||||
C314.823853,138.665009 307.760773,138.665009 300.346558,138.665009
|
|
||||||
C300.346558,111.006645 300.346558,83.281189 300.346558,55.001301
|
|
||||||
C306.163818,55.001301 312.104645,54.855133 318.024780,55.139343
|
|
||||||
C319.060455,55.189068 320.450378,56.891682 320.882477,58.112110
|
|
||||||
C321.380768,59.519447 320.998291,61.238617 320.998291,64.136040
|
|
||||||
C328.715179,54.407440 338.407898,52.804527 348.408875,54.206123
|
|
||||||
C356.403381,55.326527 361.770447,57.638248 366.682190,66.544373
|
|
||||||
C372.325470,62.972542 377.601440,58.269657 383.771973,56.014080
|
|
||||||
C396.273407,51.444298 408.602570,53.673611 419.067657,61.818150
|
|
||||||
C426.629364,67.703125 429.037811,76.770744 429.932556,86.011482
|
|
||||||
C430.332214,90.138710 430.000122,94.336792 430.000122,99.002235
|
|
||||||
z" />
|
|
||||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none" d="
|
|
||||||
M462.000427,35.006332
|
|
||||||
C462.000427,44.815434 462.000427,54.126144 462.000427,64.132019
|
|
||||||
C468.844696,58.319965 476.100769,54.654530 484.669739,53.656227
|
|
||||||
C496.686127,52.256294 507.565582,54.979622 516.927185,62.503853
|
|
||||||
C534.236755,76.416115 535.360107,106.231667 523.651062,123.341644
|
|
||||||
C516.745056,133.433182 506.539673,139.485458 493.555267,140.111023
|
|
||||||
C483.836304,140.579254 474.670624,139.889420 466.610413,133.799713
|
|
||||||
C465.039795,132.613068 463.390686,131.530289 461.957214,130.525391
|
|
||||||
C461.633789,132.375305 461.105469,135.397171 460.522095,138.733841
|
|
||||||
C454.446686,138.733841 448.017822,138.733841 441.292542,138.733841
|
|
||||||
C441.292542,99.722672 441.292542,60.652122 441.292542,21.290209
|
|
||||||
C447.943787,21.290209 454.684204,21.290209 462.000427,21.290209
|
|
||||||
C462.000427,25.636984 462.000427,30.072460 462.000427,35.006332
|
|
||||||
M480.890228,119.974937
|
|
||||||
C485.426086,119.681152 490.365997,120.444260 494.421356,118.893707
|
|
||||||
C506.182587,114.396866 510.858643,104.919495 509.036591,92.234833
|
|
||||||
C507.422546,80.997993 496.539307,71.772278 483.551605,73.864754
|
|
||||||
C469.724976,76.092384 464.376770,85.538391 463.152863,96.752327
|
|
||||||
C462.120667,106.209480 469.961761,116.189537 480.890228,119.974937
|
|
||||||
z" />
|
|
||||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none" d="
|
|
||||||
M234.797928,54.654831
|
|
||||||
C244.856339,52.605957 254.504562,52.040043 264.239868,54.923946
|
|
||||||
C279.600891,59.474377 286.402191,68.163963 289.768585,81.937614
|
|
||||||
C291.530579,89.146889 290.954620,96.927589 291.469940,105.005005
|
|
||||||
C269.550385,105.005005 248.375092,105.005005 227.094437,105.005005
|
|
||||||
C229.577957,116.288628 239.741562,120.764336 248.594757,121.034813
|
|
||||||
C256.790771,121.285217 264.390472,119.882645 271.081848,114.731178
|
|
||||||
C271.774902,114.197632 273.962708,114.659111 274.786041,115.402222
|
|
||||||
C278.726318,118.958458 282.435333,122.770882 286.509888,126.770363
|
|
||||||
C281.309174,132.968170 274.787445,135.946014 267.542938,138.175064
|
|
||||||
C253.746231,142.420120 240.209259,142.317459 227.237503,135.935410
|
|
||||||
C212.712891,128.789368 205.730453,116.523628 204.973831,100.473404
|
|
||||||
C204.537735,91.222557 205.503754,82.283119 210.008469,74.017265
|
|
||||||
C215.396210,64.131088 223.372589,57.511646 234.797928,54.654831
|
|
||||||
M266.971497,78.708908
|
|
||||||
C259.384399,70.789909 249.920425,70.480316 240.489548,73.410858
|
|
||||||
C234.405487,75.301414 229.437546,79.631561 227.800247,86.722244
|
|
||||||
C242.152313,86.722244 256.002747,86.722244 270.947815,86.722244
|
|
||||||
C269.410950,83.870155 268.228943,81.676651 266.971497,78.708908
|
|
||||||
z" />
|
|
||||||
<path fill="#FCFEFC" opacity="1.000000" stroke="none" d="
|
|
||||||
M73.196533,79.000931
|
|
||||||
C73.196594,72.566254 73.196594,66.631348 73.196594,59.785294
|
|
||||||
C94.091187,71.729622 114.247169,83.251732 135.221664,95.241745
|
|
||||||
C114.417175,107.192863 94.273178,118.764557 73.196465,130.872055
|
|
||||||
C73.196465,113.007599 73.196465,96.254150 73.196533,79.000931
|
|
||||||
z" />
|
|
||||||
</svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -1,420 +0,0 @@
|
|||||||
import Badge from '@app/components/Common/Badge';
|
|
||||||
import Button from '@app/components/Common/Button';
|
|
||||||
import CachedImage from '@app/components/Common/CachedImage';
|
|
||||||
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
|
||||||
import Header from '@app/components/Common/Header';
|
|
||||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
|
||||||
import PageTitle from '@app/components/Common/PageTitle';
|
|
||||||
import useDebouncedState from '@app/hooks/useDebouncedState';
|
|
||||||
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
|
||||||
import Error from '@app/pages/_error';
|
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
|
||||||
import {
|
|
||||||
ChevronLeftIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
MagnifyingGlassIcon,
|
|
||||||
TrashIcon,
|
|
||||||
} from '@heroicons/react/24/solid';
|
|
||||||
import type {
|
|
||||||
BlacklistItem,
|
|
||||||
BlacklistResultsResponse,
|
|
||||||
} from '@server/interfaces/api/blacklistInterfaces';
|
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import type { ChangeEvent } from 'react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useInView } from 'react-intersection-observer';
|
|
||||||
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
|
||||||
import { useToasts } from 'react-toast-notifications';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
|
|
||||||
const messages = defineMessages('components.Blacklist', {
|
|
||||||
blacklistsettings: 'Blacklist Settings',
|
|
||||||
blacklistSettingsDescription: 'Manage blacklisted media.',
|
|
||||||
mediaName: 'Name',
|
|
||||||
mediaType: 'Type',
|
|
||||||
mediaTmdbId: 'tmdb Id',
|
|
||||||
blacklistdate: 'date',
|
|
||||||
blacklistedby: '{date} by {user}',
|
|
||||||
blacklistNotFoundError: '<strong>{title}</strong> is not blacklisted.',
|
|
||||||
});
|
|
||||||
|
|
||||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
|
||||||
return (movie as MovieDetails).title !== undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Blacklist = () => {
|
|
||||||
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
|
||||||
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
|
|
||||||
useDebouncedState('');
|
|
||||||
const router = useRouter();
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const page = router.query.page ? Number(router.query.page) : 1;
|
|
||||||
const pageIndex = page - 1;
|
|
||||||
const updateQueryParams = useUpdateQueryParams({ page: page.toString() });
|
|
||||||
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
error,
|
|
||||||
mutate: revalidate,
|
|
||||||
} = useSWR<BlacklistResultsResponse>(
|
|
||||||
`/api/v1/blacklist/?take=${currentPageSize}
|
|
||||||
&skip=${pageIndex * currentPageSize}
|
|
||||||
${debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''}`,
|
|
||||||
{
|
|
||||||
refreshInterval: 0,
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// check if there's no data and no errors in the table
|
|
||||||
// so as to show a spinner inside the table and not refresh the whole component
|
|
||||||
if (!data && error) {
|
|
||||||
return <Error statusCode={500} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchItem = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
// Remove the "page" query param from the URL
|
|
||||||
// so that the "skip" query param on line 62 is empty
|
|
||||||
// and the search returns results without skipping items
|
|
||||||
if (router.query.page) router.replace(router.basePath);
|
|
||||||
|
|
||||||
setSearchFilter(e.target.value as string);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasNextPage = data && data.pageInfo.pages > pageIndex + 1;
|
|
||||||
const hasPrevPage = pageIndex > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageTitle title={[intl.formatMessage(globalMessages.blacklist)]} />
|
|
||||||
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
|
|
||||||
|
|
||||||
<div className="mt-2 flex flex-grow flex-col sm:flex-grow-0 sm:flex-row sm:justify-end">
|
|
||||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
|
|
||||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
|
||||||
<MagnifyingGlassIcon className="h-6 w-6" />
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="rounded-r-only"
|
|
||||||
value={searchFilter}
|
|
||||||
onChange={(e) => searchItem(e)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!data ? (
|
|
||||||
<LoadingSpinner />
|
|
||||||
) : data.results.length === 0 ? (
|
|
||||||
<div className="flex w-full flex-col items-center justify-center py-24 text-white">
|
|
||||||
<span className="text-2xl text-gray-400">
|
|
||||||
{intl.formatMessage(globalMessages.noresults)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
data.results.map((item: BlacklistItem) => {
|
|
||||||
return (
|
|
||||||
<div className="py-2" key={`request-list-${item.tmdbId}`}>
|
|
||||||
<BlacklistedItem item={item} revalidateList={revalidate} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="actions">
|
|
||||||
<nav
|
|
||||||
className="mb-3 flex flex-col items-center space-y-3 sm:flex-row sm:space-y-0"
|
|
||||||
aria-label="Pagination"
|
|
||||||
>
|
|
||||||
<div className="hidden lg:flex lg:flex-1">
|
|
||||||
<p className="text-sm">
|
|
||||||
{data &&
|
|
||||||
(data?.results.length ?? 0) > 0 &&
|
|
||||||
intl.formatMessage(globalMessages.showingresults, {
|
|
||||||
from: pageIndex * currentPageSize + 1,
|
|
||||||
to:
|
|
||||||
data.results.length < currentPageSize
|
|
||||||
? pageIndex * currentPageSize + data.results.length
|
|
||||||
: (pageIndex + 1) * currentPageSize,
|
|
||||||
total: data.pageInfo.results,
|
|
||||||
strong: (msg: React.ReactNode) => (
|
|
||||||
<span className="font-medium">{msg}</span>
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
|
|
||||||
<span className="-mt-3 items-center truncate text-sm sm:mt-0">
|
|
||||||
{intl.formatMessage(globalMessages.resultsperpage, {
|
|
||||||
pageSize: (
|
|
||||||
<select
|
|
||||||
id="pageSize"
|
|
||||||
name="pageSize"
|
|
||||||
onChange={(e) => {
|
|
||||||
setCurrentPageSize(Number(e.target.value));
|
|
||||||
router
|
|
||||||
.push({
|
|
||||||
pathname: router.pathname,
|
|
||||||
query: router.query.userId
|
|
||||||
? { userId: router.query.userId }
|
|
||||||
: {},
|
|
||||||
})
|
|
||||||
.then(() => window.scrollTo(0, 0));
|
|
||||||
}}
|
|
||||||
value={currentPageSize}
|
|
||||||
className="short inline"
|
|
||||||
>
|
|
||||||
<option value="5">5</option>
|
|
||||||
<option value="10">10</option>
|
|
||||||
<option value="25">25</option>
|
|
||||||
<option value="50">50</option>
|
|
||||||
<option value="100">100</option>
|
|
||||||
</select>
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-auto justify-center space-x-2 sm:flex-1 sm:justify-end">
|
|
||||||
<Button
|
|
||||||
disabled={!hasPrevPage}
|
|
||||||
onClick={() => updateQueryParams('page', (page - 1).toString())}
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon />
|
|
||||||
<span>{intl.formatMessage(globalMessages.previous)}</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={!hasNextPage}
|
|
||||||
onClick={() => updateQueryParams('page', (page + 1).toString())}
|
|
||||||
>
|
|
||||||
<span>{intl.formatMessage(globalMessages.next)}</span>
|
|
||||||
<ChevronRightIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Blacklist;
|
|
||||||
|
|
||||||
interface BlacklistedItemProps {
|
|
||||||
item: BlacklistItem;
|
|
||||||
revalidateList: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|
||||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
|
||||||
const { addToast } = useToasts();
|
|
||||||
const { ref, inView } = useInView({
|
|
||||||
triggerOnce: true,
|
|
||||||
});
|
|
||||||
const intl = useIntl();
|
|
||||||
const { hasPermission } = useUser();
|
|
||||||
|
|
||||||
const url =
|
|
||||||
item.mediaType === 'movie'
|
|
||||||
? `/api/v1/movie/${item.tmdbId}`
|
|
||||||
: `/api/v1/tv/${item.tmdbId}`;
|
|
||||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
|
||||||
inView ? url : null
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!title && !error) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="h-64 w-full animate-pulse rounded-xl bg-gray-800 xl:h-28"
|
|
||||||
ref={ref}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
|
|
||||||
setIsUpdating(true);
|
|
||||||
|
|
||||||
const res = await fetch('/api/v1/blacklist/' + tmdbId, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 204) {
|
|
||||||
addToast(
|
|
||||||
<span>
|
|
||||||
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
|
|
||||||
title,
|
|
||||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
|
||||||
})}
|
|
||||||
</span>,
|
|
||||||
{ appearance: 'success', autoDismiss: true }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
addToast(intl.formatMessage(globalMessages.blacklistError), {
|
|
||||||
appearance: 'error',
|
|
||||||
autoDismiss: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidateList();
|
|
||||||
setIsUpdating(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
|
|
||||||
{title && title.backdropPath && (
|
|
||||||
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
|
||||||
<CachedImage
|
|
||||||
type="tmdb"
|
|
||||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
|
||||||
alt=""
|
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
||||||
fill
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="absolute inset-0"
|
|
||||||
style={{
|
|
||||||
backgroundImage:
|
|
||||||
'linear-gradient(90deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 1) 100%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="relative flex w-full flex-col justify-between overflow-hidden sm:flex-row">
|
|
||||||
<div className="relative z-10 flex w-full items-center overflow-hidden pl-4 pr-4 sm:pr-0 xl:w-7/12 2xl:w-2/3">
|
|
||||||
<Link
|
|
||||||
href={
|
|
||||||
item.mediaType === 'movie'
|
|
||||||
? `/movie/${item.tmdbId}`
|
|
||||||
: `/tv/${item.tmdbId}`
|
|
||||||
}
|
|
||||||
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
|
|
||||||
>
|
|
||||||
<CachedImage
|
|
||||||
type="tmdb"
|
|
||||||
src={
|
|
||||||
title?.posterPath
|
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
|
||||||
: '/images/overseerr_poster_not_found.png'
|
|
||||||
}
|
|
||||||
alt=""
|
|
||||||
sizes="100vw"
|
|
||||||
style={{ width: '100%', height: 'auto', objectFit: 'cover' }}
|
|
||||||
width={600}
|
|
||||||
height={900}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
<div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
|
||||||
<div className="pt-0.5 text-xs font-medium text-white sm:pt-1">
|
|
||||||
{title &&
|
|
||||||
(isMovie(title)
|
|
||||||
? title.releaseDate
|
|
||||||
: title.firstAirDate
|
|
||||||
)?.slice(0, 4)}
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={
|
|
||||||
item.mediaType === 'movie'
|
|
||||||
? `/movie/${item.tmdbId}`
|
|
||||||
: `/tv/${item.tmdbId}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl">
|
|
||||||
{title && (isMovie(title) ? title.title : title.name)}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
|
|
||||||
<div className="card-field">
|
|
||||||
<span className="card-field-name">Status</span>
|
|
||||||
<Badge badgeType="danger">
|
|
||||||
{intl.formatMessage(globalMessages.blacklisted)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{item.createdAt && (
|
|
||||||
<div className="card-field">
|
|
||||||
<span className="card-field-name">
|
|
||||||
{intl.formatMessage(globalMessages.blacklisted)}
|
|
||||||
</span>
|
|
||||||
<span className="flex truncate text-sm text-gray-300">
|
|
||||||
{intl.formatMessage(messages.blacklistedby, {
|
|
||||||
date: (
|
|
||||||
<FormattedRelativeTime
|
|
||||||
value={Math.floor(
|
|
||||||
(new Date(item.createdAt).getTime() - Date.now()) / 1000
|
|
||||||
)}
|
|
||||||
updateIntervalInSeconds={1}
|
|
||||||
numeric="auto"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
user: (
|
|
||||||
<Link href={`/users/${item.user.id}`}>
|
|
||||||
<span className="group flex items-center truncate">
|
|
||||||
<CachedImage
|
|
||||||
type="avatar"
|
|
||||||
src={item.user.avatar}
|
|
||||||
alt=""
|
|
||||||
className="avatar-sm ml-1.5"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
style={{ objectFit: 'cover' }}
|
|
||||||
/>
|
|
||||||
<span className="ml-1 truncate text-sm font-semibold group-hover:text-white group-hover:underline">
|
|
||||||
{item.user.displayName}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="card-field">
|
|
||||||
{item.mediaType === 'movie' ? (
|
|
||||||
<div className="pointer-events-none z-40 self-start rounded-full border border-blue-500 bg-blue-600 bg-opacity-80 shadow-md">
|
|
||||||
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
|
|
||||||
{intl.formatMessage(globalMessages.movie)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="pointer-events-none z-40 self-start rounded-full border border-purple-600 bg-purple-600 bg-opacity-80 shadow-md">
|
|
||||||
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
|
|
||||||
{intl.formatMessage(globalMessages.tvshow)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
|
|
||||||
{hasPermission(Permission.MANAGE_BLACKLIST) && (
|
|
||||||
<ConfirmButton
|
|
||||||
onClick={() =>
|
|
||||||
removeFromBlacklist(
|
|
||||||
item.tmdbId,
|
|
||||||
title && (isMovie(title) ? title.title : title.name)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
confirmText={intl.formatMessage(
|
|
||||||
isUpdating ? globalMessages.deleting : globalMessages.areyousure
|
|
||||||
)}
|
|
||||||
className={`w-full ${
|
|
||||||
isUpdating ? 'pointer-events-none opacity-50' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<TrashIcon />
|
|
||||||
<span>
|
|
||||||
{intl.formatMessage(globalMessages.removefromBlacklist)}
|
|
||||||
</span>
|
|
||||||
</ConfirmButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import Badge from '@app/components/Common/Badge';
|
|
||||||
import Button from '@app/components/Common/Button';
|
|
||||||
import Tooltip from '@app/components/Common/Tooltip';
|
|
||||||
import { useUser } from '@app/hooks/useUser';
|
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
|
||||||
import { CalendarIcon, TrashIcon, UserIcon } from '@heroicons/react/24/solid';
|
|
||||||
import type { Blacklist } from '@server/entity/Blacklist';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useIntl } from 'react-intl';
|
|
||||||
import { useToasts } from 'react-toast-notifications';
|
|
||||||
|
|
||||||
const messages = defineMessages('component.BlacklistBlock', {
|
|
||||||
blacklistedby: 'Blacklisted By',
|
|
||||||
blacklistdate: 'Blacklisted date',
|
|
||||||
});
|
|
||||||
|
|
||||||
interface BlacklistBlockProps {
|
|
||||||
blacklistItem: Blacklist;
|
|
||||||
onUpdate?: () => void;
|
|
||||||
onDelete?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BlacklistBlock = ({
|
|
||||||
blacklistItem,
|
|
||||||
onUpdate,
|
|
||||||
onDelete,
|
|
||||||
}: BlacklistBlockProps) => {
|
|
||||||
const { user } = useUser();
|
|
||||||
const intl = useIntl();
|
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
|
||||||
const { addToast } = useToasts();
|
|
||||||
|
|
||||||
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
|
|
||||||
setIsUpdating(true);
|
|
||||||
|
|
||||||
const res = await fetch('/api/v1/blacklist/' + tmdbId, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 204) {
|
|
||||||
addToast(
|
|
||||||
<span>
|
|
||||||
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
|
|
||||||
title,
|
|
||||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
|
||||||
})}
|
|
||||||
</span>,
|
|
||||||
{ appearance: 'success', autoDismiss: true }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
addToast(intl.formatMessage(globalMessages.blacklistError), {
|
|
||||||
appearance: 'error',
|
|
||||||
autoDismiss: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdate && onUpdate();
|
|
||||||
onDelete && onDelete();
|
|
||||||
|
|
||||||
setIsUpdating(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-4 py-3 text-gray-300">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
|
|
||||||
<div className="white mb-1 flex flex-nowrap">
|
|
||||||
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
|
|
||||||
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
|
|
||||||
</Tooltip>
|
|
||||||
<span className="w-40 truncate md:w-auto">
|
|
||||||
<Link
|
|
||||||
href={
|
|
||||||
blacklistItem.user.id === user?.id
|
|
||||||
? '/profile'
|
|
||||||
: `/users/${blacklistItem.user.id}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
|
|
||||||
{blacklistItem.user.displayName}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-2 flex flex-shrink-0 flex-wrap">
|
|
||||||
<Tooltip
|
|
||||||
content={intl.formatMessage(globalMessages.removefromBlacklist)}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
buttonType="danger"
|
|
||||||
onClick={() =>
|
|
||||||
removeFromBlacklist(blacklistItem.tmdbId, blacklistItem.title)
|
|
||||||
}
|
|
||||||
disabled={isUpdating}
|
|
||||||
>
|
|
||||||
<TrashIcon className="icon-sm" />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 sm:flex sm:justify-between">
|
|
||||||
<div className="sm:flex">
|
|
||||||
<div className="mr-6 flex items-center text-sm leading-5">
|
|
||||||
<Badge badgeType="danger">
|
|
||||||
{intl.formatMessage(globalMessages.blacklisted)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex items-center text-sm leading-5 sm:mt-0">
|
|
||||||
<Tooltip content={intl.formatMessage(messages.blacklistdate)}>
|
|
||||||
<CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
|
|
||||||
</Tooltip>
|
|
||||||
<span>
|
|
||||||
{intl.formatDate(blacklistItem.createdAt, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BlacklistBlock;
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import Modal from '@app/components/Common/Modal';
|
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
|
||||||
import { Transition } from '@headlessui/react';
|
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
|
||||||
import { useIntl } from 'react-intl';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
|
|
||||||
interface BlacklistModalProps {
|
|
||||||
tmdbId: number;
|
|
||||||
type: 'movie' | 'tv' | 'collection';
|
|
||||||
show: boolean;
|
|
||||||
onComplete?: () => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
isUpdating?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = defineMessages('component.BlacklistModal', {
|
|
||||||
blacklisting: 'Blacklisting',
|
|
||||||
});
|
|
||||||
|
|
||||||
const isMovie = (
|
|
||||||
movie: MovieDetails | TvDetails | undefined
|
|
||||||
): movie is MovieDetails => {
|
|
||||||
if (!movie) return false;
|
|
||||||
return (movie as MovieDetails).title !== undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const BlacklistModal = ({
|
|
||||||
tmdbId,
|
|
||||||
type,
|
|
||||||
show,
|
|
||||||
onComplete,
|
|
||||||
onCancel,
|
|
||||||
isUpdating,
|
|
||||||
}: BlacklistModalProps) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const { data, error } = useSWR<TvDetails | MovieDetails>(
|
|
||||||
`/api/v1/${type}/${tmdbId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Transition
|
|
||||||
as="div"
|
|
||||||
enter="transition-opacity duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="transition-opacity duration-300"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
show={show}
|
|
||||||
>
|
|
||||||
<Modal
|
|
||||||
loading={!data && !error}
|
|
||||||
backgroundClickable
|
|
||||||
title={`${intl.formatMessage(globalMessages.blacklist)} ${
|
|
||||||
isMovie(data)
|
|
||||||
? intl.formatMessage(globalMessages.movie)
|
|
||||||
: intl.formatMessage(globalMessages.tvshow)
|
|
||||||
}`}
|
|
||||||
subTitle={`${isMovie(data) ? data.title : data?.name}`}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onOk={onComplete}
|
|
||||||
okText={
|
|
||||||
isUpdating
|
|
||||||
? intl.formatMessage(messages.blacklisting)
|
|
||||||
: intl.formatMessage(globalMessages.blacklist)
|
|
||||||
}
|
|
||||||
okButtonType="danger"
|
|
||||||
okDisabled={isUpdating}
|
|
||||||
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
|
|
||||||
/>
|
|
||||||
</Transition>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BlacklistModal;
|
|
||||||
@@ -183,11 +183,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blacklistVisibility = hasPermission(
|
|
||||||
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
|
|
||||||
{ type: 'or' }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="media-page"
|
className="media-page"
|
||||||
@@ -198,7 +193,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
{data.backdropPath && (
|
{data.backdropPath && (
|
||||||
<div className="media-page-bg-image">
|
<div className="media-page-bg-image">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
alt=""
|
alt=""
|
||||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
@@ -229,7 +223,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
<div className="media-header">
|
<div className="media-header">
|
||||||
<div className="media-poster">
|
<div className="media-poster">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
src={
|
src={
|
||||||
data.posterPath
|
data.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||||
@@ -342,26 +335,20 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
sliderKey="collection-movies"
|
sliderKey="collection-movies"
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
isEmpty={data.parts.length === 0}
|
isEmpty={data.parts.length === 0}
|
||||||
items={data.parts
|
items={data.parts.map((title) => (
|
||||||
.filter((title) => {
|
<TitleCard
|
||||||
if (!blacklistVisibility)
|
key={`collection-movie-${title.id}`}
|
||||||
return title.mediaInfo?.status !== MediaStatus.BLACKLISTED;
|
id={title.id}
|
||||||
return title;
|
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
||||||
})
|
image={title.posterPath}
|
||||||
.map((title) => (
|
status={title.mediaInfo?.status}
|
||||||
<TitleCard
|
summary={title.overview}
|
||||||
key={`collection-movie-${title.id}`}
|
title={title.title}
|
||||||
id={title.id}
|
userScore={title.voteAverage}
|
||||||
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
year={title.releaseDate}
|
||||||
image={title.posterPath}
|
mediaType={title.mediaType}
|
||||||
status={title.mediaInfo?.status}
|
/>
|
||||||
summary={title.overview}
|
))}
|
||||||
title={title.title}
|
|
||||||
userScore={title.voteAverage}
|
|
||||||
year={title.releaseDate}
|
|
||||||
mediaType={title.mediaType}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
/>
|
/>
|
||||||
<div className="extra-bottom-space relative" />
|
<div className="extra-bottom-space relative" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,31 +4,21 @@ import Image from 'next/image';
|
|||||||
|
|
||||||
const imageLoader: ImageLoader = ({ src }) => src;
|
const imageLoader: ImageLoader = ({ src }) => src;
|
||||||
|
|
||||||
export type CachedImageProps = ImageProps & {
|
|
||||||
src: string;
|
|
||||||
type: 'tmdb' | 'avatar';
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The CachedImage component should be used wherever
|
* The CachedImage component should be used wherever
|
||||||
* we want to offer the option to locally cache images.
|
* we want to offer the option to locally cache images.
|
||||||
**/
|
**/
|
||||||
const CachedImage = ({ src, type, ...props }: CachedImageProps) => {
|
const CachedImage = ({ src, ...props }: ImageProps) => {
|
||||||
const { currentSettings } = useSettings();
|
const { currentSettings } = useSettings();
|
||||||
|
|
||||||
let imageUrl: string;
|
let imageUrl = src;
|
||||||
|
|
||||||
if (type === 'tmdb') {
|
if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
|
||||||
// tmdb stuff
|
const parsedUrl = new URL(imageUrl);
|
||||||
imageUrl =
|
|
||||||
currentSettings.cacheImages && !src.startsWith('/')
|
if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) {
|
||||||
? src.replace(/^https:\/\/image\.tmdb\.org\//, '/imageproxy/')
|
imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy');
|
||||||
: src;
|
}
|
||||||
} else if (type === 'avatar') {
|
|
||||||
// jellyfin avatar (if any)
|
|
||||||
imageUrl = src;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />;
|
return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />;
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
className="absolute inset-0 h-full w-full"
|
className="absolute inset-0 h-full w-full"
|
||||||
alt=""
|
alt=""
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import PersonCard from '@app/components/PersonCard';
|
import PersonCard from '@app/components/PersonCard';
|
||||||
import TitleCard from '@app/components/TitleCard';
|
import TitleCard from '@app/components/TitleCard';
|
||||||
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
|
||||||
import useVerticalScroll from '@app/hooks/useVerticalScroll';
|
import useVerticalScroll from '@app/hooks/useVerticalScroll';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import { MediaStatus } from '@server/constants/media';
|
|
||||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
import type {
|
import type {
|
||||||
CollectionResult,
|
CollectionResult,
|
||||||
@@ -34,14 +32,7 @@ const ListView = ({
|
|||||||
mutateParent,
|
mutateParent,
|
||||||
}: ListViewProps) => {
|
}: ListViewProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { hasPermission } = useUser();
|
|
||||||
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
|
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
|
||||||
|
|
||||||
const blacklistVisibility = hasPermission(
|
|
||||||
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
|
|
||||||
{ type: 'or' }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isEmpty && (
|
{isEmpty && (
|
||||||
@@ -64,89 +55,76 @@ const ListView = ({
|
|||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{items
|
{items?.map((title, index) => {
|
||||||
?.filter((title) => {
|
let titleCard: React.ReactNode;
|
||||||
if (!blacklistVisibility)
|
|
||||||
return (
|
switch (title.mediaType) {
|
||||||
(title as TvResult | MovieResult).mediaInfo?.status !==
|
case 'movie':
|
||||||
MediaStatus.BLACKLISTED
|
titleCard = (
|
||||||
|
<TitleCard
|
||||||
|
key={title.id}
|
||||||
|
id={title.id}
|
||||||
|
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
||||||
|
image={title.posterPath}
|
||||||
|
status={title.mediaInfo?.status}
|
||||||
|
summary={title.overview}
|
||||||
|
title={title.title}
|
||||||
|
userScore={title.voteAverage}
|
||||||
|
year={title.releaseDate}
|
||||||
|
mediaType={title.mediaType}
|
||||||
|
inProgress={
|
||||||
|
(title.mediaInfo?.downloadStatus ?? []).length > 0
|
||||||
|
}
|
||||||
|
canExpand
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
return title;
|
break;
|
||||||
})
|
case 'tv':
|
||||||
.map((title, index) => {
|
titleCard = (
|
||||||
let titleCard: React.ReactNode;
|
<TitleCard
|
||||||
|
key={title.id}
|
||||||
|
id={title.id}
|
||||||
|
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
||||||
|
image={title.posterPath}
|
||||||
|
status={title.mediaInfo?.status}
|
||||||
|
summary={title.overview}
|
||||||
|
title={title.name}
|
||||||
|
userScore={title.voteAverage}
|
||||||
|
year={title.firstAirDate}
|
||||||
|
mediaType={title.mediaType}
|
||||||
|
inProgress={
|
||||||
|
(title.mediaInfo?.downloadStatus ?? []).length > 0
|
||||||
|
}
|
||||||
|
canExpand
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'collection':
|
||||||
|
titleCard = (
|
||||||
|
<TitleCard
|
||||||
|
id={title.id}
|
||||||
|
image={title.posterPath}
|
||||||
|
summary={title.overview}
|
||||||
|
title={title.title}
|
||||||
|
mediaType={title.mediaType}
|
||||||
|
canExpand
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'person':
|
||||||
|
titleCard = (
|
||||||
|
<PersonCard
|
||||||
|
personId={title.id}
|
||||||
|
name={title.name}
|
||||||
|
profilePath={title.profilePath}
|
||||||
|
canExpand
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
switch (title.mediaType) {
|
return <li key={`${title.id}-${index}`}>{titleCard}</li>;
|
||||||
case 'movie':
|
})}
|
||||||
titleCard = (
|
|
||||||
<TitleCard
|
|
||||||
key={title.id}
|
|
||||||
id={title.id}
|
|
||||||
isAddedToWatchlist={
|
|
||||||
title.mediaInfo?.watchlists?.length ?? 0
|
|
||||||
}
|
|
||||||
image={title.posterPath}
|
|
||||||
status={title.mediaInfo?.status}
|
|
||||||
summary={title.overview}
|
|
||||||
title={title.title}
|
|
||||||
userScore={title.voteAverage}
|
|
||||||
year={title.releaseDate}
|
|
||||||
mediaType={title.mediaType}
|
|
||||||
inProgress={
|
|
||||||
(title.mediaInfo?.downloadStatus ?? []).length > 0
|
|
||||||
}
|
|
||||||
canExpand
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'tv':
|
|
||||||
titleCard = (
|
|
||||||
<TitleCard
|
|
||||||
key={title.id}
|
|
||||||
id={title.id}
|
|
||||||
isAddedToWatchlist={
|
|
||||||
title.mediaInfo?.watchlists?.length ?? 0
|
|
||||||
}
|
|
||||||
image={title.posterPath}
|
|
||||||
status={title.mediaInfo?.status}
|
|
||||||
summary={title.overview}
|
|
||||||
title={title.name}
|
|
||||||
userScore={title.voteAverage}
|
|
||||||
year={title.firstAirDate}
|
|
||||||
mediaType={title.mediaType}
|
|
||||||
inProgress={
|
|
||||||
(title.mediaInfo?.downloadStatus ?? []).length > 0
|
|
||||||
}
|
|
||||||
canExpand
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'collection':
|
|
||||||
titleCard = (
|
|
||||||
<TitleCard
|
|
||||||
id={title.id}
|
|
||||||
image={title.posterPath}
|
|
||||||
summary={title.overview}
|
|
||||||
title={title.title}
|
|
||||||
mediaType={title.mediaType}
|
|
||||||
canExpand
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'person':
|
|
||||||
titleCard = (
|
|
||||||
<PersonCard
|
|
||||||
personId={title.id}
|
|
||||||
name={title.name}
|
|
||||||
profilePath={title.profilePath}
|
|
||||||
canExpand
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <li key={`${title.id}-${index}`}>{titleCard}</li>;
|
|
||||||
})}
|
|
||||||
{isLoading &&
|
{isLoading &&
|
||||||
!isReachingEnd &&
|
!isReachingEnd &&
|
||||||
[...Array(20)].map((_item, i) => (
|
[...Array(20)].map((_item, i) => (
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
{backdrop && (
|
{backdrop && (
|
||||||
<div className="absolute top-0 left-0 right-0 z-0 h-64 max-h-full w-full">
|
<div className="absolute top-0 left-0 right-0 z-0 h-64 max-h-full w-full">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
alt=""
|
alt=""
|
||||||
src={backdrop}
|
src={backdrop}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import Spinner from '@app/assets/spinner.svg';
|
import Spinner from '@app/assets/spinner.svg';
|
||||||
import { CheckCircleIcon } from '@heroicons/react/20/solid';
|
import { CheckCircleIcon } from '@heroicons/react/20/solid';
|
||||||
import {
|
import { BellIcon, ClockIcon, MinusSmallIcon } from '@heroicons/react/24/solid';
|
||||||
BellIcon,
|
|
||||||
ClockIcon,
|
|
||||||
EyeSlashIcon,
|
|
||||||
MinusSmallIcon,
|
|
||||||
} from '@heroicons/react/24/solid';
|
|
||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
|
|
||||||
interface StatusBadgeMiniProps {
|
interface StatusBadgeMiniProps {
|
||||||
@@ -49,10 +44,6 @@ const StatusBadgeMini = ({
|
|||||||
);
|
);
|
||||||
indicatorIcon = <BellIcon />;
|
indicatorIcon = <BellIcon />;
|
||||||
break;
|
break;
|
||||||
case MediaStatus.BLACKLISTED:
|
|
||||||
badgeStyle.push('bg-red-500 border-white-400 ring-white-400 text-white');
|
|
||||||
indicatorIcon = <EyeSlashIcon />;
|
|
||||||
break;
|
|
||||||
case MediaStatus.PARTIALLY_AVAILABLE:
|
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||||
badgeStyle.push(
|
badgeStyle.push(
|
||||||
'bg-green-500 border-green-400 ring-green-400 text-green-100'
|
'bg-green-500 border-green-400 ring-green-400 text-green-100'
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => {
|
|||||||
>
|
>
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
src={image}
|
src={image}
|
||||||
alt={name}
|
alt={name}
|
||||||
className="relative z-40 h-full w-full"
|
className="relative z-40 h-full w-full"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
CompanySelector,
|
CompanySelector,
|
||||||
GenreSelector,
|
GenreSelector,
|
||||||
KeywordSelector,
|
KeywordSelector,
|
||||||
StatusSelector,
|
|
||||||
WatchProviderSelector,
|
WatchProviderSelector,
|
||||||
} from '@app/components/Selector';
|
} from '@app/components/Selector';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
@@ -41,7 +40,6 @@ const messages = defineMessages('components.Discover.FilterSlideover', {
|
|||||||
runtime: 'Runtime',
|
runtime: 'Runtime',
|
||||||
streamingservices: 'Streaming Services',
|
streamingservices: 'Streaming Services',
|
||||||
voteCount: 'Number of votes between {minValue} and {maxValue}',
|
voteCount: 'Number of votes between {minValue} and {maxValue}',
|
||||||
status: 'Status',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type FilterSlideoverProps = {
|
type FilterSlideoverProps = {
|
||||||
@@ -152,23 +150,6 @@ const FilterSlideover = ({
|
|||||||
updateQueryParams('genre', value?.map((v) => v.value).join(','));
|
updateQueryParams('genre', value?.map((v) => v.value).join(','));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{type === 'tv' && (
|
|
||||||
<>
|
|
||||||
<span className="text-lg font-semibold">
|
|
||||||
{intl.formatMessage(messages.status)}
|
|
||||||
</span>
|
|
||||||
<StatusSelector
|
|
||||||
defaultValue={currentFilters.status}
|
|
||||||
isMulti
|
|
||||||
onChange={(value) => {
|
|
||||||
updateQueryParams(
|
|
||||||
'status',
|
|
||||||
value?.map((v) => v.value).join('|')
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span className="text-lg font-semibold">
|
<span className="text-lg font-semibold">
|
||||||
{intl.formatMessage(messages.keywords)}
|
{intl.formatMessage(messages.keywords)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ export const QueryFilterOptions = z.object({
|
|||||||
voteCountGte: z.string().optional(),
|
voteCountGte: z.string().optional(),
|
||||||
watchRegion: z.string().optional(),
|
watchRegion: z.string().optional(),
|
||||||
watchProviders: z.string().optional(),
|
watchProviders: z.string().optional(),
|
||||||
status: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||||
@@ -148,10 +147,6 @@ export const prepareFilterValues = (
|
|||||||
filterValues.genre = values.genre;
|
filterValues.genre = values.genre;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.status) {
|
|
||||||
filterValues.status = values.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (values.keywords) {
|
if (values.keywords) {
|
||||||
filterValues.keywords = values.keywords;
|
filterValues.keywords = values.keywords;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import useLocale from '@app/hooks/useLocale';
|
|||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import getConfig from 'next/config';
|
||||||
|
|
||||||
interface ExternalLinkBlockProps {
|
interface ExternalLinkBlockProps {
|
||||||
mediaType: 'movie' | 'tv';
|
mediaType: 'movie' | 'tv';
|
||||||
@@ -30,6 +31,7 @@ const ExternalLinkBlock = ({
|
|||||||
mediaUrl,
|
mediaUrl,
|
||||||
}: ExternalLinkBlockProps) => {
|
}: ExternalLinkBlockProps) => {
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
const { publicRuntimeConfig } = getConfig();
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,8 +45,7 @@ const ExternalLinkBlock = ({
|
|||||||
>
|
>
|
||||||
{settings.currentSettings.mediaServerType === MediaServerType.PLEX ? (
|
{settings.currentSettings.mediaServerType === MediaServerType.PLEX ? (
|
||||||
<PlexLogo />
|
<PlexLogo />
|
||||||
) : settings.currentSettings.mediaServerType ===
|
) : publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? (
|
||||||
MediaServerType.EMBY ? (
|
|
||||||
<EmbyLogo />
|
<EmbyLogo />
|
||||||
) : (
|
) : (
|
||||||
<JellyfinLogo />
|
<JellyfinLogo />
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ const GenreCard = ({ image, url, name, canExpand = false }: GenreCardProps) => {
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
src={image}
|
src={image}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import CachedImage from '@app/components/Common/CachedImage';
|
|
||||||
import Modal from '@app/components/Common/Modal';
|
import Modal from '@app/components/Common/Modal';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
@@ -7,6 +6,7 @@ import { Menu, Transition } from '@headlessui/react';
|
|||||||
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
|
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
|
||||||
import type { default as IssueCommentType } from '@server/entity/IssueComment';
|
import type { default as IssueCommentType } from '@server/entity/IssueComment';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Fragment, useState } from 'react';
|
import { Fragment, useState } from 'react';
|
||||||
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
||||||
@@ -88,8 +88,7 @@ const IssueComment = ({
|
|||||||
</Modal>
|
</Modal>
|
||||||
</Transition>
|
</Transition>
|
||||||
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
|
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
|
||||||
<CachedImage
|
<Image
|
||||||
type="avatar"
|
|
||||||
src={comment.user.avatar}
|
src={comment.user.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-10 w-10 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
className="h-10 w-10 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import type Issue from '@server/entity/Issue';
|
|||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
import getConfig from 'next/config';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -106,6 +108,7 @@ const IssueDetails = () => {
|
|||||||
(opt) => opt.issueType === issueData?.issueType
|
(opt) => opt.issueType === issueData?.issueType
|
||||||
);
|
);
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
const { publicRuntimeConfig } = getConfig();
|
||||||
|
|
||||||
if (!data && !error) {
|
if (!data && !error) {
|
||||||
return <LoadingSpinner />;
|
return <LoadingSpinner />;
|
||||||
@@ -217,7 +220,6 @@ const IssueDetails = () => {
|
|||||||
{data.backdropPath && (
|
{data.backdropPath && (
|
||||||
<div className="media-page-bg-image">
|
<div className="media-page-bg-image">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
alt=""
|
alt=""
|
||||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
@@ -236,7 +238,6 @@ const IssueDetails = () => {
|
|||||||
<div className="media-header">
|
<div className="media-header">
|
||||||
<div className="media-poster">
|
<div className="media-poster">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
src={
|
src={
|
||||||
data.posterPath
|
data.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||||
@@ -288,11 +289,10 @@ const IssueDetails = () => {
|
|||||||
}
|
}
|
||||||
className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
|
className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
|
||||||
>
|
>
|
||||||
<CachedImage
|
<Image
|
||||||
type="avatar"
|
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
|
||||||
src={issueData.createdBy.avatar}
|
src={issueData.createdBy.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
|
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
/>
|
/>
|
||||||
@@ -390,8 +390,7 @@ const IssueDetails = () => {
|
|||||||
>
|
>
|
||||||
<PlayIcon />
|
<PlayIcon />
|
||||||
<span>
|
<span>
|
||||||
{settings.currentSettings.mediaServerType ===
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
MediaServerType.EMBY
|
|
||||||
? intl.formatMessage(messages.playonplex, {
|
? intl.formatMessage(messages.playonplex, {
|
||||||
mediaServerName: 'Emby',
|
mediaServerName: 'Emby',
|
||||||
})
|
})
|
||||||
@@ -438,8 +437,7 @@ const IssueDetails = () => {
|
|||||||
>
|
>
|
||||||
<PlayIcon />
|
<PlayIcon />
|
||||||
<span>
|
<span>
|
||||||
{settings.currentSettings.mediaServerType ===
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
MediaServerType.EMBY
|
|
||||||
? intl.formatMessage(messages.play4konplex, {
|
? intl.formatMessage(messages.play4konplex, {
|
||||||
mediaServerName: 'Emby',
|
mediaServerName: 'Emby',
|
||||||
})
|
})
|
||||||
@@ -664,8 +662,7 @@ const IssueDetails = () => {
|
|||||||
>
|
>
|
||||||
<PlayIcon />
|
<PlayIcon />
|
||||||
<span>
|
<span>
|
||||||
{settings.currentSettings.mediaServerType ===
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
MediaServerType.EMBY
|
|
||||||
? intl.formatMessage(messages.playonplex, {
|
? intl.formatMessage(messages.playonplex, {
|
||||||
mediaServerName: 'Emby',
|
mediaServerName: 'Emby',
|
||||||
})
|
})
|
||||||
@@ -711,8 +708,7 @@ const IssueDetails = () => {
|
|||||||
>
|
>
|
||||||
<PlayIcon />
|
<PlayIcon />
|
||||||
<span>
|
<span>
|
||||||
{settings.currentSettings.mediaServerType ===
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
MediaServerType.EMBY
|
|
||||||
? intl.formatMessage(messages.play4konplex, {
|
? intl.formatMessage(messages.play4konplex, {
|
||||||
mediaServerName: 'Emby',
|
mediaServerName: 'Emby',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { MediaType } from '@server/constants/media';
|
|||||||
import type Issue from '@server/entity/Issue';
|
import type Issue from '@server/entity/Issue';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
||||||
@@ -112,7 +113,6 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
{title.backdropPath && (
|
{title.backdropPath && (
|
||||||
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
@@ -138,7 +138,6 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
|
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
|
||||||
>
|
>
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
src={
|
src={
|
||||||
title.posterPath
|
title.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||||
@@ -227,8 +226,7 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
href={`/users/${issue.createdBy.id}`}
|
href={`/users/${issue.createdBy.id}`}
|
||||||
className="group flex items-center truncate"
|
className="group flex items-center truncate"
|
||||||
>
|
>
|
||||||
<CachedImage
|
<Image
|
||||||
type="avatar"
|
|
||||||
src={issue.createdBy.avatar}
|
src={issue.createdBy.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="avatar-sm ml-1.5 object-cover"
|
className="avatar-sm ml-1.5 object-cover"
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
CogIcon,
|
CogIcon,
|
||||||
EllipsisHorizontalIcon,
|
EllipsisHorizontalIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
EyeSlashIcon,
|
|
||||||
FilmIcon,
|
FilmIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TvIcon,
|
TvIcon,
|
||||||
@@ -17,7 +16,6 @@ import {
|
|||||||
ClockIcon as FilledClockIcon,
|
ClockIcon as FilledClockIcon,
|
||||||
CogIcon as FilledCogIcon,
|
CogIcon as FilledCogIcon,
|
||||||
ExclamationTriangleIcon as FilledExclamationTriangleIcon,
|
ExclamationTriangleIcon as FilledExclamationTriangleIcon,
|
||||||
EyeSlashIcon as FilledEyeSlashIcon,
|
|
||||||
FilmIcon as FilledFilmIcon,
|
FilmIcon as FilledFilmIcon,
|
||||||
SparklesIcon as FilledSparklesIcon,
|
SparklesIcon as FilledSparklesIcon,
|
||||||
TvIcon as FilledTvIcon,
|
TvIcon as FilledTvIcon,
|
||||||
@@ -86,18 +84,6 @@ const MobileMenu = () => {
|
|||||||
svgIconSelected: <FilledClockIcon className="h-6 w-6" />,
|
svgIconSelected: <FilledClockIcon className="h-6 w-6" />,
|
||||||
activeRegExp: /^\/requests/,
|
activeRegExp: /^\/requests/,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: '/blacklist',
|
|
||||||
content: intl.formatMessage(menuMessages.blacklist),
|
|
||||||
svgIcon: <EyeSlashIcon className="h-6 w-6" />,
|
|
||||||
svgIconSelected: <FilledEyeSlashIcon className="h-6 w-6" />,
|
|
||||||
activeRegExp: /^\/blacklist/,
|
|
||||||
requiredPermission: [
|
|
||||||
Permission.MANAGE_BLACKLIST,
|
|
||||||
Permission.VIEW_BLACKLIST,
|
|
||||||
],
|
|
||||||
permissionType: 'or',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: '/issues',
|
href: '/issues',
|
||||||
content: intl.formatMessage(menuMessages.issues),
|
content: intl.formatMessage(menuMessages.issues),
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
ClockIcon,
|
ClockIcon,
|
||||||
CogIcon,
|
CogIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
EyeSlashIcon,
|
|
||||||
FilmIcon,
|
FilmIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TvIcon,
|
TvIcon,
|
||||||
@@ -26,7 +25,6 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', {
|
|||||||
browsemovies: 'Movies',
|
browsemovies: 'Movies',
|
||||||
browsetv: 'Series',
|
browsetv: 'Series',
|
||||||
requests: 'Requests',
|
requests: 'Requests',
|
||||||
blacklist: 'Blacklist',
|
|
||||||
issues: 'Issues',
|
issues: 'Issues',
|
||||||
users: 'Users',
|
users: 'Users',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
@@ -73,17 +71,6 @@ const SidebarLinks: SidebarLinkProps[] = [
|
|||||||
svgIcon: <ClockIcon className="mr-3 h-6 w-6" />,
|
svgIcon: <ClockIcon className="mr-3 h-6 w-6" />,
|
||||||
activeRegExp: /^\/requests/,
|
activeRegExp: /^\/requests/,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: '/blacklist',
|
|
||||||
messagesKey: 'blacklist',
|
|
||||||
svgIcon: <EyeSlashIcon className="mr-3 h-6 w-6" />,
|
|
||||||
activeRegExp: /^\/blacklist/,
|
|
||||||
requiredPermission: [
|
|
||||||
Permission.MANAGE_BLACKLIST,
|
|
||||||
Permission.VIEW_BLACKLIST,
|
|
||||||
],
|
|
||||||
permissionType: 'or',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: '/issues',
|
href: '/issues',
|
||||||
messagesKey: 'issues',
|
messagesKey: 'issues',
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import CachedImage from '@app/components/Common/CachedImage';
|
|
||||||
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
|
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
@@ -8,6 +7,7 @@ import {
|
|||||||
ClockIcon,
|
ClockIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
|
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
|
||||||
|
import Image from 'next/image';
|
||||||
import type { LinkProps } from 'next/link';
|
import type { LinkProps } from 'next/link';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { forwardRef, Fragment } from 'react';
|
import { forwardRef, Fragment } from 'react';
|
||||||
@@ -56,10 +56,9 @@ const UserDropdown = () => {
|
|||||||
className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500"
|
className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500"
|
||||||
data-testid="user-menu"
|
data-testid="user-menu"
|
||||||
>
|
>
|
||||||
<CachedImage
|
<Image
|
||||||
type="avatar"
|
|
||||||
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
||||||
src={user ? user.avatar : ''}
|
src={user?.avatar || ''}
|
||||||
alt=""
|
alt=""
|
||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={40}
|
||||||
@@ -80,10 +79,9 @@ const UserDropdown = () => {
|
|||||||
<div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur">
|
<div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur">
|
||||||
<div className="flex flex-col space-y-4 px-4 py-4">
|
<div className="flex flex-col space-y-4 px-4 py-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<CachedImage
|
<Image
|
||||||
type="avatar"
|
|
||||||
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
||||||
src={user ? user.avatar : ''}
|
src={user?.avatar || ''}
|
||||||
alt=""
|
alt=""
|
||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={40}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import useSettings from '@app/hooks/useSettings';
|
|||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { InformationCircleIcon } from '@heroicons/react/24/solid';
|
import { InformationCircleIcon } from '@heroicons/react/24/solid';
|
||||||
import { ApiErrorCode } from '@server/constants/error';
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
import { MediaServerType, ServerType } from '@server/constants/server';
|
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import { FormattedMessage, useIntl } from 'react-intl';
|
import getConfig from 'next/config';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
@@ -26,7 +26,6 @@ const messages = defineMessages('components.Login', {
|
|||||||
validationemailformat: 'Valid email required',
|
validationemailformat: 'Valid email required',
|
||||||
validationusernamerequired: 'Username required',
|
validationusernamerequired: 'Username required',
|
||||||
validationpasswordrequired: 'Password required',
|
validationpasswordrequired: 'Password required',
|
||||||
validationservertyperequired: 'Please select a server type',
|
|
||||||
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
||||||
validationPortRequired: 'You must provide a valid port number',
|
validationPortRequired: 'You must provide a valid port number',
|
||||||
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||||
@@ -41,51 +40,42 @@ const messages = defineMessages('components.Login', {
|
|||||||
initialsigningin: 'Connecting…',
|
initialsigningin: 'Connecting…',
|
||||||
initialsignin: 'Connect',
|
initialsignin: 'Connect',
|
||||||
forgotpassword: 'Forgot Password?',
|
forgotpassword: 'Forgot Password?',
|
||||||
servertype: 'Server Type',
|
|
||||||
back: 'Go back',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
interface JellyfinLoginProps {
|
interface JellyfinLoginProps {
|
||||||
revalidate: () => void;
|
revalidate: () => void;
|
||||||
initial?: boolean;
|
initial?: boolean;
|
||||||
serverType?: MediaServerType;
|
|
||||||
onCancel?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||||
revalidate,
|
revalidate,
|
||||||
initial,
|
initial,
|
||||||
serverType,
|
|
||||||
onCancel,
|
|
||||||
}) => {
|
}) => {
|
||||||
const toasts = useToasts();
|
const toasts = useToasts();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
const { publicRuntimeConfig } = getConfig();
|
||||||
const mediaServerFormatValues = {
|
|
||||||
mediaServerName:
|
|
||||||
serverType === MediaServerType.JELLYFIN
|
|
||||||
? ServerType.JELLYFIN
|
|
||||||
: serverType === MediaServerType.EMBY
|
|
||||||
? ServerType.EMBY
|
|
||||||
: 'Media Server',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (initial) {
|
if (initial) {
|
||||||
const LoginSchema = Yup.object().shape({
|
const LoginSchema = Yup.object().shape({
|
||||||
hostname: Yup.string().required(
|
hostname: Yup.string().required(
|
||||||
intl.formatMessage(
|
intl.formatMessage(messages.validationhostrequired, {
|
||||||
messages.validationhostrequired,
|
mediaServerName:
|
||||||
mediaServerFormatValues
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||||
)
|
})
|
||||||
),
|
),
|
||||||
port: Yup.number().required(
|
port: Yup.number().required(
|
||||||
intl.formatMessage(messages.validationPortRequired)
|
intl.formatMessage(messages.validationPortRequired)
|
||||||
),
|
),
|
||||||
urlBase: Yup.string().matches(
|
urlBase: Yup.string()
|
||||||
/^(.*[^/])$/,
|
.matches(
|
||||||
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
|
/^(\/[^/].*[^/]$)/,
|
||||||
),
|
intl.formatMessage(messages.validationUrlBaseLeadingSlash)
|
||||||
|
)
|
||||||
|
.matches(
|
||||||
|
/^(.*[^/])$/,
|
||||||
|
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
|
||||||
|
),
|
||||||
email: Yup.string()
|
email: Yup.string()
|
||||||
.email(intl.formatMessage(messages.validationemailformat))
|
.email(intl.formatMessage(messages.validationemailformat))
|
||||||
.required(intl.formatMessage(messages.validationemailrequired)),
|
.required(intl.formatMessage(messages.validationemailrequired)),
|
||||||
@@ -95,6 +85,11 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
password: Yup.string(),
|
password: Yup.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mediaServerFormatValues = {
|
||||||
|
mediaServerName:
|
||||||
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
@@ -109,11 +104,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
validationSchema={LoginSchema}
|
validationSchema={LoginSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
// Check if serverType is either 'Jellyfin' or 'Emby'
|
|
||||||
// if (serverType !== 'Jellyfin' && serverType !== 'Emby') {
|
|
||||||
// throw new Error('Invalid serverType'); // You can customize the error message
|
|
||||||
// }
|
|
||||||
|
|
||||||
const res = await fetch('/api/v1/auth/jellyfin', {
|
const res = await fetch('/api/v1/auth/jellyfin', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -127,7 +117,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
useSsl: values.useSsl,
|
useSsl: values.useSsl,
|
||||||
urlBase: values.urlBase,
|
urlBase: values.urlBase,
|
||||||
email: values.email,
|
email: values.email,
|
||||||
serverType: serverType,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||||
@@ -323,7 +312,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||||
<div className="flex flex-row-reverse justify-between">
|
<div className="flex justify-end">
|
||||||
<span className="inline-flex rounded-md shadow-sm">
|
<span className="inline-flex rounded-md shadow-sm">
|
||||||
<Button
|
<Button
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
@@ -335,13 +324,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
: intl.formatMessage(messages.signin)}
|
: intl.formatMessage(messages.signin)}
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
{onCancel && (
|
|
||||||
<span className="inline-flex rounded-md shadow-sm">
|
|
||||||
<Button buttonType="default" onClick={() => onCancel()}>
|
|
||||||
<FormattedMessage {...messages.back} />
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -447,8 +429,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
jellyfinForgotPasswordUrl
|
jellyfinForgotPasswordUrl
|
||||||
? `${jellyfinForgotPasswordUrl}`
|
? `${jellyfinForgotPasswordUrl}`
|
||||||
: `${baseUrl}/web/index.html#!/${
|
: `${baseUrl}/web/index.html#!/${
|
||||||
settings.currentSettings.mediaServerType ===
|
process.env.JELLYFIN_TYPE === 'emby'
|
||||||
MediaServerType.EMBY
|
|
||||||
? 'startup/'
|
? 'startup/'
|
||||||
: ''
|
: ''
|
||||||
}forgotpassword.html`
|
}forgotpassword.html`
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import defineMessages from '@app/utils/defineMessages';
|
|||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { XCircleIcon } from '@heroicons/react/24/solid';
|
import { XCircleIcon } from '@heroicons/react/24/solid';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import getConfig from 'next/config';
|
||||||
import { useRouter } from 'next/dist/client/router';
|
import { useRouter } from 'next/dist/client/router';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
@@ -33,6 +34,7 @@ const Login = () => {
|
|||||||
const { user, revalidate } = useUser();
|
const { user, revalidate } = useUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
const { publicRuntimeConfig } = getConfig();
|
||||||
|
|
||||||
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
|
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
|
||||||
// We take the token and attempt to sign in. If we get a success message, we will
|
// We take the token and attempt to sign in. If we get a success message, we will
|
||||||
@@ -86,15 +88,6 @@ const Login = () => {
|
|||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mediaServerFormatValues = {
|
|
||||||
mediaServerName:
|
|
||||||
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
|
||||||
? 'Jellyfin'
|
|
||||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
|
||||||
? 'Emby'
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen flex-col bg-gray-900 py-14">
|
<div className="relative flex min-h-screen flex-col bg-gray-900 py-14">
|
||||||
<PageTitle title={intl.formatMessage(messages.signin)} />
|
<PageTitle title={intl.formatMessage(messages.signin)} />
|
||||||
@@ -161,10 +154,12 @@ const Login = () => {
|
|||||||
{settings.currentSettings.mediaServerType ==
|
{settings.currentSettings.mediaServerType ==
|
||||||
MediaServerType.PLEX
|
MediaServerType.PLEX
|
||||||
? intl.formatMessage(messages.signinwithplex)
|
? intl.formatMessage(messages.signinwithplex)
|
||||||
: intl.formatMessage(
|
: intl.formatMessage(messages.signinwithjellyfin, {
|
||||||
messages.signinwithjellyfin,
|
mediaServerName:
|
||||||
mediaServerFormatValues
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
)}
|
? 'Emby'
|
||||||
|
: 'Jellyfin',
|
||||||
|
})}
|
||||||
</button>
|
</button>
|
||||||
<AccordionContent isOpen={openIndexes.includes(0)}>
|
<AccordionContent isOpen={openIndexes.includes(0)}>
|
||||||
<div className="px-10 py-8">
|
<div className="px-10 py-8">
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import BlacklistBlock from '@app/components/BlacklistBlock';
|
|
||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import CachedImage from '@app/components/Common/CachedImage';
|
|
||||||
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
||||||
import SlideOver from '@app/components/Common/SlideOver';
|
import SlideOver from '@app/components/Common/SlideOver';
|
||||||
import Tooltip from '@app/components/Common/Tooltip';
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
@@ -28,6 +26,8 @@ import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfa
|
|||||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
|
import getConfig from 'next/config';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
@@ -95,6 +95,7 @@ const ManageSlideOver = ({
|
|||||||
const { user: currentUser, hasPermission } = useUser();
|
const { user: currentUser, hasPermission } = useUser();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
const { publicRuntimeConfig } = getConfig();
|
||||||
const { data: watchData } = useSWR<MediaWatchDataResponse>(
|
const { data: watchData } = useSWR<MediaWatchDataResponse>(
|
||||||
settings.currentSettings.mediaServerType === MediaServerType.PLEX &&
|
settings.currentSettings.mediaServerType === MediaServerType.PLEX &&
|
||||||
data.mediaInfo &&
|
data.mediaInfo &&
|
||||||
@@ -285,20 +286,6 @@ const ManageSlideOver = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data.mediaInfo?.status === MediaStatus.BLACKLISTED && (
|
|
||||||
<div>
|
|
||||||
<h3 className="mb-2 text-xl font-bold">
|
|
||||||
{intl.formatMessage(globalMessages.blacklist)}
|
|
||||||
</h3>
|
|
||||||
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
|
||||||
<BlacklistBlock
|
|
||||||
blacklistItem={data.mediaInfo.blacklist}
|
|
||||||
onUpdate={() => revalidate()}
|
|
||||||
onDelete={() => onClose()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasPermission(Permission.ADMIN) &&
|
{hasPermission(Permission.ADMIN) &&
|
||||||
(data.mediaInfo?.serviceUrl ||
|
(data.mediaInfo?.serviceUrl ||
|
||||||
data.mediaInfo?.tautulliUrl ||
|
data.mediaInfo?.tautulliUrl ||
|
||||||
@@ -368,8 +355,7 @@ const ManageSlideOver = ({
|
|||||||
key={`watch-user-${user.id}`}
|
key={`watch-user-${user.id}`}
|
||||||
content={user.displayName}
|
content={user.displayName}
|
||||||
>
|
>
|
||||||
<CachedImage
|
<Image
|
||||||
type="avatar"
|
|
||||||
src={user.avatar}
|
src={user.avatar}
|
||||||
alt={user.displayName}
|
alt={user.displayName}
|
||||||
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||||
@@ -530,8 +516,7 @@ const ManageSlideOver = ({
|
|||||||
key={`watch-user-${user.id}`}
|
key={`watch-user-${user.id}`}
|
||||||
content={user.displayName}
|
content={user.displayName}
|
||||||
>
|
>
|
||||||
<CachedImage
|
<Image
|
||||||
type="avatar"
|
|
||||||
src={user.avatar}
|
src={user.avatar}
|
||||||
alt={user.displayName}
|
alt={user.displayName}
|
||||||
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||||
@@ -620,17 +605,32 @@ const ManageSlideOver = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasPermission(Permission.ADMIN) &&
|
{hasPermission(Permission.ADMIN) && data?.mediaInfo && (
|
||||||
data?.mediaInfo &&
|
<div>
|
||||||
data.mediaInfo.status !== MediaStatus.BLACKLISTED && (
|
<h3 className="mb-2 text-xl font-bold">
|
||||||
<div>
|
{intl.formatMessage(messages.manageModalAdvanced)}
|
||||||
<h3 className="mb-2 text-xl font-bold">
|
</h3>
|
||||||
{intl.formatMessage(messages.manageModalAdvanced)}
|
<div className="space-y-2">
|
||||||
</h3>
|
{data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
|
||||||
<div className="space-y-2">
|
<Button
|
||||||
{data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
|
onClick={() => markAvailable()}
|
||||||
|
className="w-full"
|
||||||
|
buttonType="success"
|
||||||
|
>
|
||||||
|
<CheckCircleIcon />
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(
|
||||||
|
mediaType === 'movie'
|
||||||
|
? messages.markavailable
|
||||||
|
: messages.markallseasonsavailable
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
||||||
|
settings.currentSettings.series4kEnabled && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => markAvailable()}
|
onClick={() => markAvailable(true)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
buttonType="success"
|
buttonType="success"
|
||||||
>
|
>
|
||||||
@@ -638,59 +638,41 @@ const ManageSlideOver = ({
|
|||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
mediaType === 'movie'
|
mediaType === 'movie'
|
||||||
? messages.markavailable
|
? messages.mark4kavailable
|
||||||
: messages.markallseasonsavailable
|
: messages.markallseasons4kavailable
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
<div>
|
||||||
settings.currentSettings.series4kEnabled && (
|
<ConfirmButton
|
||||||
<Button
|
onClick={() => deleteMedia()}
|
||||||
onClick={() => markAvailable(true)}
|
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
buttonType="success"
|
>
|
||||||
>
|
<DocumentMinusIcon />
|
||||||
<CheckCircleIcon />
|
<span>
|
||||||
<span>
|
{intl.formatMessage(messages.manageModalClearMedia)}
|
||||||
{intl.formatMessage(
|
</span>
|
||||||
mediaType === 'movie'
|
</ConfirmButton>
|
||||||
? messages.mark4kavailable
|
<div className="mt-2 text-xs text-gray-400">
|
||||||
: messages.markallseasons4kavailable
|
{intl.formatMessage(messages.manageModalClearMediaWarning, {
|
||||||
)}
|
mediaType: intl.formatMessage(
|
||||||
</span>
|
mediaType === 'movie' ? messages.movie : messages.tvshow
|
||||||
</Button>
|
),
|
||||||
)}
|
mediaServerName:
|
||||||
<div>
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
<ConfirmButton
|
? 'Emby'
|
||||||
onClick={() => deleteMedia()}
|
: settings.currentSettings.mediaServerType ===
|
||||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
MediaServerType.PLEX
|
||||||
className="w-full"
|
? 'Plex'
|
||||||
>
|
: 'Jellyfin',
|
||||||
<DocumentMinusIcon />
|
})}
|
||||||
<span>
|
|
||||||
{intl.formatMessage(messages.manageModalClearMedia)}
|
|
||||||
</span>
|
|
||||||
</ConfirmButton>
|
|
||||||
<div className="mt-2 text-xs text-gray-400">
|
|
||||||
{intl.formatMessage(messages.manageModalClearMediaWarning, {
|
|
||||||
mediaType: intl.formatMessage(
|
|
||||||
mediaType === 'movie' ? messages.movie : messages.tvshow
|
|
||||||
),
|
|
||||||
mediaServerName:
|
|
||||||
settings.currentSettings.mediaServerType ===
|
|
||||||
MediaServerType.EMBY
|
|
||||||
? 'Emby'
|
|
||||||
: settings.currentSettings.mediaServerType ===
|
|
||||||
MediaServerType.PLEX
|
|
||||||
? 'Plex'
|
|
||||||
: 'Jellyfin',
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SlideOver>
|
</SlideOver>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import PersonCard from '@app/components/PersonCard';
|
|||||||
import Slider from '@app/components/Slider';
|
import Slider from '@app/components/Slider';
|
||||||
import TitleCard from '@app/components/TitleCard';
|
import TitleCard from '@app/components/TitleCard';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
|
||||||
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
import { Permission } from '@server/lib/permissions';
|
|
||||||
import type {
|
import type {
|
||||||
MovieResult,
|
MovieResult,
|
||||||
PersonResult,
|
PersonResult,
|
||||||
@@ -43,7 +41,6 @@ const MediaSlider = ({
|
|||||||
onNewTitles,
|
onNewTitles,
|
||||||
}: MediaSliderProps) => {
|
}: MediaSliderProps) => {
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const { hasPermission } = useUser();
|
|
||||||
const { data, error, setSize, size } = useSWRInfinite<MixedResult>(
|
const { data, error, setSize, size } = useSWRInfinite<MixedResult>(
|
||||||
(pageIndex: number, previousPageData: MixedResult | null) => {
|
(pageIndex: number, previousPageData: MixedResult | null) => {
|
||||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||||
@@ -93,65 +90,50 @@ const MediaSlider = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blacklistVisibility = hasPermission(
|
const finalTitles = titles.slice(0, 20).map((title) => {
|
||||||
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
|
switch (title.mediaType) {
|
||||||
{ type: 'or' }
|
case 'movie':
|
||||||
);
|
|
||||||
|
|
||||||
const finalTitles = titles
|
|
||||||
.slice(0, 20)
|
|
||||||
.filter((title) => {
|
|
||||||
if (!blacklistVisibility)
|
|
||||||
return (
|
return (
|
||||||
(title as TvResult | MovieResult).mediaInfo?.status !==
|
<TitleCard
|
||||||
MediaStatus.BLACKLISTED
|
key={title.id}
|
||||||
|
id={title.id}
|
||||||
|
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
||||||
|
image={title.posterPath}
|
||||||
|
status={title.mediaInfo?.status}
|
||||||
|
summary={title.overview}
|
||||||
|
title={title.title}
|
||||||
|
userScore={title.voteAverage}
|
||||||
|
year={title.releaseDate}
|
||||||
|
mediaType={title.mediaType}
|
||||||
|
inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
return title;
|
case 'tv':
|
||||||
})
|
return (
|
||||||
.map((title) => {
|
<TitleCard
|
||||||
switch (title.mediaType) {
|
key={title.id}
|
||||||
case 'movie':
|
id={title.id}
|
||||||
return (
|
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
||||||
<TitleCard
|
image={title.posterPath}
|
||||||
key={title.id}
|
status={title.mediaInfo?.status}
|
||||||
id={title.id}
|
summary={title.overview}
|
||||||
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
title={title.name}
|
||||||
image={title.posterPath}
|
userScore={title.voteAverage}
|
||||||
status={title.mediaInfo?.status}
|
year={title.firstAirDate}
|
||||||
summary={title.overview}
|
mediaType={title.mediaType}
|
||||||
title={title.title}
|
inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0}
|
||||||
userScore={title.voteAverage}
|
/>
|
||||||
year={title.releaseDate}
|
);
|
||||||
mediaType={title.mediaType}
|
case 'person':
|
||||||
inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0}
|
return (
|
||||||
/>
|
<PersonCard
|
||||||
);
|
personId={title.id}
|
||||||
case 'tv':
|
name={title.name}
|
||||||
return (
|
profilePath={title.profilePath}
|
||||||
<TitleCard
|
/>
|
||||||
key={title.id}
|
);
|
||||||
id={title.id}
|
}
|
||||||
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
});
|
||||||
image={title.posterPath}
|
|
||||||
status={title.mediaInfo?.status}
|
|
||||||
summary={title.overview}
|
|
||||||
title={title.name}
|
|
||||||
userScore={title.voteAverage}
|
|
||||||
year={title.firstAirDate}
|
|
||||||
mediaType={title.mediaType}
|
|
||||||
inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'person':
|
|
||||||
return (
|
|
||||||
<PersonCard
|
|
||||||
personId={title.id}
|
|
||||||
name={title.name}
|
|
||||||
profilePath={title.profilePath}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (linkUrl && titles.length > 20) {
|
if (linkUrl && titles.length > 20) {
|
||||||
finalTitles.push(
|
finalTitles.push(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import RTRotten from '@app/assets/rt_rotten.svg';
|
|||||||
import ImdbLogo from '@app/assets/services/imdb.svg';
|
import ImdbLogo from '@app/assets/services/imdb.svg';
|
||||||
import Spinner from '@app/assets/spinner.svg';
|
import Spinner from '@app/assets/spinner.svg';
|
||||||
import TmdbLogo from '@app/assets/tmdb_logo.svg';
|
import TmdbLogo from '@app/assets/tmdb_logo.svg';
|
||||||
import BlacklistModal from '@app/components/BlacklistModal';
|
|
||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import CachedImage from '@app/components/Common/CachedImage';
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
@@ -36,7 +35,6 @@ import {
|
|||||||
CloudIcon,
|
CloudIcon,
|
||||||
CogIcon,
|
CogIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
EyeSlashIcon,
|
|
||||||
FilmIcon,
|
FilmIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
TicketIcon,
|
TicketIcon,
|
||||||
@@ -55,9 +53,10 @@ import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
|
|||||||
import { countries } from 'country-flag-icons';
|
import { countries } from 'country-flag-icons';
|
||||||
import 'country-flag-icons/3x2/flags.css';
|
import 'country-flag-icons/3x2/flags.css';
|
||||||
import { uniqBy } from 'lodash';
|
import { uniqBy } from 'lodash';
|
||||||
|
import getConfig from 'next/config';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
@@ -127,9 +126,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
|
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
|
||||||
!movie?.onUserWatchlist
|
!movie?.onUserWatchlist
|
||||||
);
|
);
|
||||||
const [isBlacklistUpdating, setIsBlacklistUpdating] =
|
const { publicRuntimeConfig } = getConfig();
|
||||||
useState<boolean>(false);
|
|
||||||
const [showBlacklistModal, setShowBlacklistModal] = useState(false);
|
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -160,11 +157,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
setShowManager(router.query.manage == '1' ? true : false);
|
setShowManager(router.query.manage == '1' ? true : false);
|
||||||
}, [router.query.manage]);
|
}, [router.query.manage]);
|
||||||
|
|
||||||
const closeBlacklistModal = useCallback(
|
|
||||||
() => setShowBlacklistModal(false),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
||||||
mediaUrl: data?.mediaInfo?.mediaUrl,
|
mediaUrl: data?.mediaInfo?.mediaUrl,
|
||||||
mediaUrl4k: data?.mediaInfo?.mediaUrl4k,
|
mediaUrl4k: data?.mediaInfo?.mediaUrl4k,
|
||||||
@@ -287,7 +279,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
?.flatrate ?? [];
|
?.flatrate ?? [];
|
||||||
|
|
||||||
function getAvalaibleMediaServerName() {
|
function getAvalaibleMediaServerName() {
|
||||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
|
||||||
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,8 +291,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAvalaible4kMediaServerName() {
|
function getAvalaible4kMediaServerName() {
|
||||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
|
||||||
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
return intl.formatMessage(messages.play4k, { mediaServerName: 'Emby' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) {
|
if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) {
|
||||||
@@ -384,60 +376,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickHideItemBtn = async (): Promise<void> => {
|
|
||||||
setIsBlacklistUpdating(true);
|
|
||||||
|
|
||||||
const res = await fetch('/api/v1/blacklist', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
tmdbId: movie?.id,
|
|
||||||
mediaType: 'movie',
|
|
||||||
title: movie?.title,
|
|
||||||
user: user?.id,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 201) {
|
|
||||||
addToast(
|
|
||||||
<span>
|
|
||||||
{intl.formatMessage(globalMessages.blacklistSuccess, {
|
|
||||||
title: movie?.title,
|
|
||||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
|
||||||
})}
|
|
||||||
</span>,
|
|
||||||
{ appearance: 'success', autoDismiss: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
revalidate();
|
|
||||||
} else if (res.status === 412) {
|
|
||||||
addToast(
|
|
||||||
<span>
|
|
||||||
{intl.formatMessage(globalMessages.blacklistDuplicateError, {
|
|
||||||
title: movie?.title,
|
|
||||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
|
||||||
})}
|
|
||||||
</span>,
|
|
||||||
{ appearance: 'info', autoDismiss: true }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
addToast(intl.formatMessage(globalMessages.blacklistError), {
|
|
||||||
appearance: 'error',
|
|
||||||
autoDismiss: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsBlacklistUpdating(false);
|
|
||||||
closeBlacklistModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const showHideButton = hasPermission([Permission.MANAGE_BLACKLIST], {
|
|
||||||
type: 'or',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="media-page"
|
className="media-page"
|
||||||
@@ -448,7 +386,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
{data.backdropPath && (
|
{data.backdropPath && (
|
||||||
<div className="media-page-bg-image">
|
<div className="media-page-bg-image">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
alt=""
|
alt=""
|
||||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
@@ -484,18 +421,9 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
revalidate={() => revalidate()}
|
revalidate={() => revalidate()}
|
||||||
show={showManager}
|
show={showManager}
|
||||||
/>
|
/>
|
||||||
<BlacklistModal
|
|
||||||
tmdbId={data.id}
|
|
||||||
type="movie"
|
|
||||||
show={showBlacklistModal}
|
|
||||||
onCancel={closeBlacklistModal}
|
|
||||||
onComplete={onClickHideItemBtn}
|
|
||||||
isUpdating={isBlacklistUpdating}
|
|
||||||
/>
|
|
||||||
<div className="media-header">
|
<div className="media-header">
|
||||||
<div className="media-poster">
|
<div className="media-poster">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
src={
|
src={
|
||||||
data.posterPath
|
data.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||||
@@ -569,61 +497,40 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="media-actions">
|
<div className="media-actions">
|
||||||
{showHideButton &&
|
<>
|
||||||
data?.mediaInfo?.status !== MediaStatus.PROCESSING &&
|
{toggleWatchlist ? (
|
||||||
data?.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
<Tooltip content={intl.formatMessage(messages.addtowatchlist)}>
|
||||||
data?.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE &&
|
|
||||||
data?.mediaInfo?.status !== MediaStatus.PENDING &&
|
|
||||||
data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && (
|
|
||||||
<Tooltip
|
|
||||||
content={intl.formatMessage(globalMessages.addToBlacklist)}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
buttonType={'ghost'}
|
buttonType={'ghost'}
|
||||||
className="z-40 mr-2"
|
className="z-40 mr-2"
|
||||||
buttonSize={'md'}
|
buttonSize={'md'}
|
||||||
onClick={() => setShowBlacklistModal(true)}
|
onClick={onClickWatchlistBtn}
|
||||||
>
|
>
|
||||||
<EyeSlashIcon className={'h-3'} />
|
{isUpdating ? (
|
||||||
|
<Spinner className="h-3" />
|
||||||
|
) : (
|
||||||
|
<StarIcon className={'h-3 text-amber-300'} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip
|
||||||
|
content={intl.formatMessage(messages.removefromwatchlist)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className="z-40 mr-2"
|
||||||
|
buttonSize={'md'}
|
||||||
|
onClick={onClickDeleteWatchlistBtn}
|
||||||
|
>
|
||||||
|
{isUpdating ? (
|
||||||
|
<Spinner className="h-3" />
|
||||||
|
) : (
|
||||||
|
<MinusCircleIcon className={'h-3'} />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && (
|
</>
|
||||||
<>
|
|
||||||
{toggleWatchlist ? (
|
|
||||||
<Tooltip content={intl.formatMessage(messages.addtowatchlist)}>
|
|
||||||
<Button
|
|
||||||
buttonType={'ghost'}
|
|
||||||
className="z-40 mr-2"
|
|
||||||
buttonSize={'md'}
|
|
||||||
onClick={onClickWatchlistBtn}
|
|
||||||
>
|
|
||||||
{isUpdating ? (
|
|
||||||
<Spinner className="h-3" />
|
|
||||||
) : (
|
|
||||||
<StarIcon className={'h-3 text-amber-300'} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<Tooltip
|
|
||||||
content={intl.formatMessage(messages.removefromwatchlist)}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
className="z-40 mr-2"
|
|
||||||
buttonSize={'md'}
|
|
||||||
onClick={onClickDeleteWatchlistBtn}
|
|
||||||
>
|
|
||||||
{isUpdating ? (
|
|
||||||
<Spinner className="h-3" />
|
|
||||||
) : (
|
|
||||||
<MinusCircleIcon className={'h-3'} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<PlayButton links={mediaLinks} />
|
<PlayButton links={mediaLinks} />
|
||||||
<RequestButton
|
<RequestButton
|
||||||
mediaType="movie"
|
mediaType="movie"
|
||||||
@@ -743,7 +650,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
<div className="group relative z-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-lg bg-gray-800 bg-cover bg-center shadow-md ring-1 ring-gray-700 transition duration-300 hover:scale-105 hover:ring-gray-500">
|
<div className="group relative z-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-lg bg-gray-800 bg-cover bg-center shadow-md ring-1 ring-gray-700 transition duration-300 hover:scale-105 hover:ring-gray-500">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
src={`https://image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath}`}
|
src={`https://image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath}`}
|
||||||
alt=""
|
alt=""
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -78,13 +78,6 @@ export const messages = defineMessages('components.PermissionEdit', {
|
|||||||
viewwatchlists: 'View {mediaServerName} Watchlists',
|
viewwatchlists: 'View {mediaServerName} Watchlists',
|
||||||
viewwatchlistsDescription:
|
viewwatchlistsDescription:
|
||||||
"Grant permission to view other users' {mediaServerName} Watchlists.",
|
"Grant permission to view other users' {mediaServerName} Watchlists.",
|
||||||
manageblacklist: 'Manage Blacklist',
|
|
||||||
manageblacklistDescription: 'Grant permission to manage blacklisted media.',
|
|
||||||
blacklistedItems: 'Blacklist media.',
|
|
||||||
blacklistedItemsDescription: 'Grant permission to blacklist media.',
|
|
||||||
viewblacklistedItems: 'View blacklisted media.',
|
|
||||||
viewblacklistedItemsDescription:
|
|
||||||
'Grant permission to view blacklisted media.',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
interface PermissionEditProps {
|
interface PermissionEditProps {
|
||||||
@@ -339,22 +332,6 @@ export const PermissionEdit = ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'manageblacklist',
|
|
||||||
name: intl.formatMessage(messages.manageblacklist),
|
|
||||||
description: intl.formatMessage(messages.manageblacklistDescription),
|
|
||||||
permission: Permission.MANAGE_BLACKLIST,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 'viewblacklisteditems',
|
|
||||||
name: intl.formatMessage(messages.viewblacklistedItems),
|
|
||||||
description: intl.formatMessage(
|
|
||||||
messages.viewblacklistedItemsDescription
|
|
||||||
),
|
|
||||||
permission: Permission.VIEW_BLACKLIST,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ const PersonCard = ({
|
|||||||
{profilePath ? (
|
{profilePath ? (
|
||||||
<div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700">
|
<div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
|
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
|
||||||
alt=""
|
alt=""
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -227,7 +227,6 @@ const PersonDetails = () => {
|
|||||||
{data.profilePath && (
|
{data.profilePath && (
|
||||||
<div className="relative mb-6 mr-0 h-36 w-36 flex-shrink-0 overflow-hidden rounded-full ring-1 ring-gray-700 lg:mb-0 lg:mr-6 lg:h-44 lg:w-44">
|
<div className="relative mb-6 mr-0 h-36 w-36 flex-shrink-0 overflow-hidden rounded-full ring-1 ring-gray-700 lg:mb-0 lg:mr-6 lg:h-44 lg:w-44">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath}`}
|
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath}`}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
|||||||
@@ -300,7 +300,6 @@ const RequestButton = ({
|
|||||||
}) &&
|
}) &&
|
||||||
media &&
|
media &&
|
||||||
media.status !== MediaStatus.AVAILABLE &&
|
media.status !== MediaStatus.AVAILABLE &&
|
||||||
media.status !== MediaStatus.BLACKLISTED &&
|
|
||||||
!isShowComplete
|
!isShowComplete
|
||||||
) {
|
) {
|
||||||
buttons.push({
|
buttons.push({
|
||||||
@@ -346,7 +345,6 @@ const RequestButton = ({
|
|||||||
}) &&
|
}) &&
|
||||||
media &&
|
media &&
|
||||||
media.status4k !== MediaStatus.AVAILABLE &&
|
media.status4k !== MediaStatus.AVAILABLE &&
|
||||||
media.status !== MediaStatus.BLACKLISTED &&
|
|
||||||
!is4kShowComplete &&
|
!is4kShowComplete &&
|
||||||
settings.currentSettings.series4kEnabled
|
settings.currentSettings.series4kEnabled
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
|
|||||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
@@ -115,8 +116,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
|||||||
className="group flex items-center"
|
className="group flex items-center"
|
||||||
>
|
>
|
||||||
<span className="avatar-sm">
|
<span className="avatar-sm">
|
||||||
<CachedImage
|
<Image
|
||||||
type="avatar"
|
|
||||||
src={requestData.requestedBy.avatar}
|
src={requestData.requestedBy.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="avatar-sm object-cover"
|
className="avatar-sm object-cover"
|
||||||
@@ -346,7 +346,6 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
{title.backdropPath && (
|
{title.backdropPath && (
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
alt=""
|
alt=""
|
||||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
@@ -391,8 +390,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
className="group flex items-center"
|
className="group flex items-center"
|
||||||
>
|
>
|
||||||
<span className="avatar-sm">
|
<span className="avatar-sm">
|
||||||
<CachedImage
|
<Image
|
||||||
type="avatar"
|
|
||||||
src={requestData.requestedBy.avatar}
|
src={requestData.requestedBy.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="avatar-sm object-cover"
|
className="avatar-sm object-cover"
|
||||||
@@ -605,7 +603,6 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
className="w-20 flex-shrink-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-md shadow-sm transition duration-300 hover:scale-105 hover:shadow-md sm:w-28"
|
className="w-20 flex-shrink-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-md shadow-sm transition duration-300 hover:scale-105 hover:shadow-md sm:w-28"
|
||||||
>
|
>
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
src={
|
src={
|
||||||
title.posterPath
|
title.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
|
|||||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
@@ -42,7 +43,6 @@ const messages = defineMessages('components.RequestList.RequestItem', {
|
|||||||
tmdbid: 'TMDB ID',
|
tmdbid: 'TMDB ID',
|
||||||
tvdbid: 'TheTVDB ID',
|
tvdbid: 'TheTVDB ID',
|
||||||
unknowntitle: 'Unknown Title',
|
unknowntitle: 'Unknown Title',
|
||||||
removearr: 'Remove from {arr}',
|
|
||||||
profileName: 'Profile',
|
profileName: 'Profile',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,8 +190,7 @@ const RequestItemError = ({
|
|||||||
className="group flex items-center truncate"
|
className="group flex items-center truncate"
|
||||||
>
|
>
|
||||||
<span className="avatar-sm ml-1.5">
|
<span className="avatar-sm ml-1.5">
|
||||||
<CachedImage
|
<Image
|
||||||
type="avatar"
|
|
||||||
src={requestData.requestedBy.avatar}
|
src={requestData.requestedBy.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="avatar-sm object-cover"
|
className="avatar-sm object-cover"
|
||||||
@@ -250,8 +249,7 @@ const RequestItemError = ({
|
|||||||
className="group flex items-center truncate"
|
className="group flex items-center truncate"
|
||||||
>
|
>
|
||||||
<span className="avatar-sm ml-1.5">
|
<span className="avatar-sm ml-1.5">
|
||||||
<CachedImage
|
<Image
|
||||||
type="avatar"
|
|
||||||
src={requestData.modifiedBy.avatar}
|
src={requestData.modifiedBy.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="avatar-sm object-cover"
|
className="avatar-sm object-cover"
|
||||||
@@ -344,18 +342,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
revalidateList();
|
revalidateList();
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteMediaFile = async () => {
|
|
||||||
if (request.media) {
|
|
||||||
await fetch(`/api/v1/media/${request.media.id}/file`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
await fetch(`/api/v1/media/${request.media.id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
revalidateList();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const retryRequest = async () => {
|
const retryRequest = async () => {
|
||||||
setRetrying(true);
|
setRetrying(true);
|
||||||
|
|
||||||
@@ -420,7 +406,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
{title.backdropPath && (
|
{title.backdropPath && (
|
||||||
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
@@ -446,7 +431,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
|
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
|
||||||
>
|
>
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
src={
|
src={
|
||||||
title.posterPath
|
title.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||||
@@ -573,8 +557,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
className="group flex items-center truncate"
|
className="group flex items-center truncate"
|
||||||
>
|
>
|
||||||
<span className="avatar-sm ml-1.5">
|
<span className="avatar-sm ml-1.5">
|
||||||
<CachedImage
|
<Image
|
||||||
type="avatar"
|
|
||||||
src={requestData.requestedBy.avatar}
|
src={requestData.requestedBy.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="avatar-sm object-cover"
|
className="avatar-sm object-cover"
|
||||||
@@ -633,9 +616,8 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
className="group flex items-center truncate"
|
className="group flex items-center truncate"
|
||||||
>
|
>
|
||||||
<span className="avatar-sm ml-1.5">
|
<span className="avatar-sm ml-1.5">
|
||||||
<CachedImage
|
<Image
|
||||||
type="avatar"
|
src={requestData.requestedBy.avatar}
|
||||||
src={requestData.modifiedBy.avatar}
|
|
||||||
alt=""
|
alt=""
|
||||||
className="avatar-sm object-cover"
|
className="avatar-sm object-cover"
|
||||||
width={20}
|
width={20}
|
||||||
@@ -685,28 +667,14 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
)}
|
)}
|
||||||
{requestData.status !== MediaRequestStatus.PENDING &&
|
{requestData.status !== MediaRequestStatus.PENDING &&
|
||||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||||
<>
|
<ConfirmButton
|
||||||
<ConfirmButton
|
onClick={() => deleteRequest()}
|
||||||
onClick={() => deleteRequest()}
|
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
className="w-full"
|
||||||
className="w-full"
|
>
|
||||||
>
|
<TrashIcon />
|
||||||
<TrashIcon />
|
<span>{intl.formatMessage(messages.deleterequest)}</span>
|
||||||
<span>{intl.formatMessage(messages.deleterequest)}</span>
|
</ConfirmButton>
|
||||||
</ConfirmButton>
|
|
||||||
<ConfirmButton
|
|
||||||
onClick={() => deleteMediaFile()}
|
|
||||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<TrashIcon />
|
|
||||||
<span>
|
|
||||||
{intl.formatMessage(messages.removearr, {
|
|
||||||
arr: request.type === 'movie' ? 'Radarr' : 'Sonarr',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</ConfirmButton>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{requestData.status === MediaRequestStatus.PENDING &&
|
{requestData.status === MediaRequestStatus.PENDING &&
|
||||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
import CachedImage from '@app/components/Common/CachedImage';
|
|
||||||
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
|
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
|
||||||
import type { User } from '@app/hooks/useUser';
|
import type { User } from '@app/hooks/useUser';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
@@ -15,6 +14,7 @@ import type {
|
|||||||
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
|
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
|
||||||
import { hasPermission } from '@server/lib/permissions';
|
import { hasPermission } from '@server/lib/permissions';
|
||||||
import { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
|
import Image from 'next/image';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import Select from 'react-select';
|
import Select from 'react-select';
|
||||||
@@ -561,8 +561,7 @@ const AdvancedRequester = ({
|
|||||||
<span className="inline-block w-full rounded-md shadow-sm">
|
<span className="inline-block w-full rounded-md shadow-sm">
|
||||||
<Listbox.Button className="focus:shadow-outline-blue relative w-full cursor-default rounded-md border border-gray-700 bg-gray-800 py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5">
|
<Listbox.Button className="focus:shadow-outline-blue relative w-full cursor-default rounded-md border border-gray-700 bg-gray-800 py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5">
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<CachedImage
|
<Image
|
||||||
type="avatar"
|
|
||||||
src={selectedUser.avatar}
|
src={selectedUser.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
|
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
|
||||||
@@ -614,8 +613,7 @@ const AdvancedRequester = ({
|
|||||||
selected ? 'font-semibold' : 'font-normal'
|
selected ? 'font-semibold' : 'font-normal'
|
||||||
} flex items-center`}
|
} flex items-center`}
|
||||||
>
|
>
|
||||||
<CachedImage
|
<Image
|
||||||
type="avatar"
|
|
||||||
src={user.avatar}
|
src={user.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
|
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
|
||||||
|
|||||||
@@ -66,9 +66,7 @@ const CollectionRequestModal = ({
|
|||||||
(quota?.movie.remaining ?? 0) - selectedParts.length;
|
(quota?.movie.remaining ?? 0) - selectedParts.length;
|
||||||
|
|
||||||
const getAllParts = (): number[] => {
|
const getAllParts = (): number[] => {
|
||||||
return (data?.parts ?? [])
|
return (data?.parts ?? []).map((part) => part.id);
|
||||||
.filter((part) => part.mediaInfo?.status !== MediaStatus.BLACKLISTED)
|
|
||||||
.map((part) => part.id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAllRequestedParts = (): number[] => {
|
const getAllRequestedParts = (): number[] => {
|
||||||
@@ -250,11 +248,6 @@ const CollectionRequestModal = ({
|
|||||||
{ type: 'or' }
|
{ type: 'or' }
|
||||||
);
|
);
|
||||||
|
|
||||||
const blacklistVisibility = hasPermission(
|
|
||||||
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
|
|
||||||
{ type: 'or' }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
loading={(!data && !error) || !quota}
|
loading={(!data && !error) || !quota}
|
||||||
@@ -351,157 +344,122 @@ const CollectionRequestModal = ({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-700">
|
<tbody className="divide-y divide-gray-700">
|
||||||
{data?.parts
|
{data?.parts.map((part) => {
|
||||||
.filter((part) => {
|
const partRequest = getPartRequest(part.id);
|
||||||
if (!blacklistVisibility)
|
const partMedia =
|
||||||
return (
|
part.mediaInfo &&
|
||||||
part.mediaInfo?.status !== MediaStatus.BLACKLISTED
|
part.mediaInfo[is4k ? 'status4k' : 'status'] !==
|
||||||
);
|
MediaStatus.UNKNOWN
|
||||||
return part;
|
? part.mediaInfo
|
||||||
})
|
: undefined;
|
||||||
.map((part) => {
|
|
||||||
const partRequest = getPartRequest(part.id);
|
|
||||||
const partMedia =
|
|
||||||
part.mediaInfo &&
|
|
||||||
part.mediaInfo[is4k ? 'status4k' : 'status'] !==
|
|
||||||
MediaStatus.UNKNOWN
|
|
||||||
? part.mediaInfo
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={`part-${part.id}`}>
|
<tr key={`part-${part.id}`}>
|
||||||
<td
|
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
|
||||||
className={`whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100 ${
|
<span
|
||||||
partMedia?.status === MediaStatus.BLACKLISTED &&
|
role="checkbox"
|
||||||
'pointer-events-none opacity-50'
|
tabIndex={0}
|
||||||
|
aria-checked={
|
||||||
|
!!partMedia || isSelectedPart(part.id)
|
||||||
|
}
|
||||||
|
onClick={() => togglePart(part.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === 'Space') {
|
||||||
|
togglePart(part.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${
|
||||||
|
!!partMedia ||
|
||||||
|
partRequest ||
|
||||||
|
(quota?.movie.limit &&
|
||||||
|
currentlyRemaining <= 0 &&
|
||||||
|
!isSelectedPart(part.id))
|
||||||
|
? 'opacity-50'
|
||||||
|
: ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
role="checkbox"
|
aria-hidden="true"
|
||||||
tabIndex={0}
|
className={`${
|
||||||
aria-checked={
|
!!partMedia ||
|
||||||
(!!partMedia &&
|
|
||||||
partMedia.status !==
|
|
||||||
MediaStatus.BLACKLISTED) ||
|
|
||||||
isSelectedPart(part.id)
|
|
||||||
}
|
|
||||||
onClick={() => togglePart(part.id)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === 'Space') {
|
|
||||||
togglePart(part.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${
|
|
||||||
(!!partMedia &&
|
|
||||||
partMedia.status !==
|
|
||||||
MediaStatus.BLACKLISTED) ||
|
|
||||||
partRequest ||
|
partRequest ||
|
||||||
(quota?.movie.limit &&
|
isSelectedPart(part.id)
|
||||||
currentlyRemaining <= 0 &&
|
? 'bg-indigo-500'
|
||||||
!isSelectedPart(part.id))
|
: 'bg-gray-700'
|
||||||
? 'opacity-50'
|
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
||||||
: ''
|
></span>
|
||||||
}`}
|
<span
|
||||||
>
|
aria-hidden="true"
|
||||||
<span
|
className={`${
|
||||||
aria-hidden="true"
|
!!partMedia ||
|
||||||
className={`${
|
partRequest ||
|
||||||
(!!partMedia &&
|
isSelectedPart(part.id)
|
||||||
partMedia.status !==
|
? 'translate-x-5'
|
||||||
MediaStatus.BLACKLISTED) ||
|
: 'translate-x-0'
|
||||||
partRequest ||
|
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||||
isSelectedPart(part.id)
|
></span>
|
||||||
? 'bg-indigo-500'
|
</span>
|
||||||
: 'bg-gray-700'
|
</td>
|
||||||
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
<td className="flex items-center px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
|
||||||
></span>
|
<div className="relative h-auto w-10 flex-shrink-0 overflow-hidden rounded-md">
|
||||||
<span
|
<CachedImage
|
||||||
aria-hidden="true"
|
src={
|
||||||
className={`${
|
part.posterPath
|
||||||
(!!partMedia &&
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${part.posterPath}`
|
||||||
partMedia.status !==
|
: '/images/overseerr_poster_not_found.png'
|
||||||
MediaStatus.BLACKLISTED) ||
|
}
|
||||||
partRequest ||
|
alt=""
|
||||||
isSelectedPart(part.id)
|
sizes="100vw"
|
||||||
? 'translate-x-5'
|
style={{
|
||||||
: 'translate-x-0'
|
width: '100%',
|
||||||
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
height: 'auto',
|
||||||
></span>
|
objectFit: 'cover',
|
||||||
</span>
|
}}
|
||||||
</td>
|
width={600}
|
||||||
<td
|
height={900}
|
||||||
className={`flex items-center px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6 ${
|
/>
|
||||||
partMedia?.status === MediaStatus.BLACKLISTED &&
|
</div>
|
||||||
'pointer-events-none opacity-50'
|
<div className="flex flex-col justify-center pl-2">
|
||||||
}`}
|
<div className="text-xs font-medium">
|
||||||
>
|
{part.releaseDate?.slice(0, 4)}
|
||||||
<div className="relative h-auto w-10 flex-shrink-0 overflow-hidden rounded-md">
|
|
||||||
<CachedImage
|
|
||||||
type="tmdb"
|
|
||||||
src={
|
|
||||||
part.posterPath
|
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${part.posterPath}`
|
|
||||||
: '/images/overseerr_poster_not_found.png'
|
|
||||||
}
|
|
||||||
alt=""
|
|
||||||
sizes="100vw"
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: 'auto',
|
|
||||||
objectFit: 'cover',
|
|
||||||
}}
|
|
||||||
width={600}
|
|
||||||
height={900}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-center pl-2">
|
<div className="text-base font-bold">
|
||||||
<div className="text-xs font-medium">
|
{part.title}
|
||||||
{part.releaseDate?.slice(0, 4)}
|
|
||||||
</div>
|
|
||||||
<div className="text-base font-bold">
|
|
||||||
{part.title}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td className="whitespace-nowrap py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6">
|
</td>
|
||||||
{!partMedia && !partRequest && (
|
<td className="whitespace-nowrap py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6">
|
||||||
<Badge>
|
{!partMedia && !partRequest && (
|
||||||
{intl.formatMessage(
|
<Badge>
|
||||||
globalMessages.notrequested
|
{intl.formatMessage(globalMessages.notrequested)}
|
||||||
)}
|
</Badge>
|
||||||
|
)}
|
||||||
|
{!partMedia &&
|
||||||
|
partRequest?.status ===
|
||||||
|
MediaRequestStatus.PENDING && (
|
||||||
|
<Badge badgeType="warning">
|
||||||
|
{intl.formatMessage(globalMessages.pending)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{!partMedia &&
|
{((!partMedia &&
|
||||||
partRequest?.status ===
|
partRequest?.status ===
|
||||||
MediaRequestStatus.PENDING && (
|
MediaRequestStatus.APPROVED) ||
|
||||||
<Badge badgeType="warning">
|
partMedia?.[is4k ? 'status4k' : 'status'] ===
|
||||||
{intl.formatMessage(globalMessages.pending)}
|
MediaStatus.PROCESSING) && (
|
||||||
</Badge>
|
<Badge badgeType="primary">
|
||||||
)}
|
{intl.formatMessage(globalMessages.requested)}
|
||||||
{((!partMedia &&
|
</Badge>
|
||||||
partRequest?.status ===
|
)}
|
||||||
MediaRequestStatus.APPROVED) ||
|
{partMedia?.[is4k ? 'status4k' : 'status'] ===
|
||||||
partMedia?.[is4k ? 'status4k' : 'status'] ===
|
MediaStatus.AVAILABLE && (
|
||||||
MediaStatus.PROCESSING) && (
|
<Badge badgeType="success">
|
||||||
<Badge badgeType="primary">
|
{intl.formatMessage(globalMessages.available)}
|
||||||
{intl.formatMessage(globalMessages.requested)}
|
</Badge>
|
||||||
</Badge>
|
)}
|
||||||
)}
|
</td>
|
||||||
{partMedia?.[is4k ? 'status4k' : 'status'] ===
|
</tr>
|
||||||
MediaStatus.AVAILABLE && (
|
);
|
||||||
<Badge badgeType="success">
|
})}
|
||||||
{intl.formatMessage(globalMessages.available)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{partMedia?.status === MediaStatus.BLACKLISTED && (
|
|
||||||
<Badge badgeType="danger">
|
|
||||||
{intl.formatMessage(globalMessages.blacklisted)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,13 +33,6 @@ const messages = defineMessages('components.Selector', {
|
|||||||
nooptions: 'No results.',
|
nooptions: 'No results.',
|
||||||
showmore: 'Show More',
|
showmore: 'Show More',
|
||||||
showless: 'Show Less',
|
showless: 'Show Less',
|
||||||
searchStatus: 'Select status...',
|
|
||||||
returningSeries: 'Returning Series',
|
|
||||||
planned: 'Planned',
|
|
||||||
inProduction: 'In Production',
|
|
||||||
ended: 'Ended',
|
|
||||||
canceled: 'Canceled',
|
|
||||||
pilot: 'Pilot',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type SingleVal = {
|
type SingleVal = {
|
||||||
@@ -211,75 +204,6 @@ export const GenreSelector = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StatusSelector = ({
|
|
||||||
isMulti,
|
|
||||||
defaultValue,
|
|
||||||
onChange,
|
|
||||||
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const [defaultDataValue, setDefaultDataValue] = useState<
|
|
||||||
{ label: string; value: number }[] | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const options = useMemo(
|
|
||||||
() => [
|
|
||||||
{ name: intl.formatMessage(messages.returningSeries), id: 0 },
|
|
||||||
{ name: intl.formatMessage(messages.planned), id: 1 },
|
|
||||||
{ name: intl.formatMessage(messages.inProduction), id: 2 },
|
|
||||||
{ name: intl.formatMessage(messages.ended), id: 3 },
|
|
||||||
{ name: intl.formatMessage(messages.canceled), id: 4 },
|
|
||||||
{ name: intl.formatMessage(messages.pilot), id: 5 },
|
|
||||||
],
|
|
||||||
[intl]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadDefaultStatus = async (): Promise<void> => {
|
|
||||||
if (!defaultValue) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const statuses = defaultValue.split('|');
|
|
||||||
|
|
||||||
const statusData = options
|
|
||||||
.filter((opt) => statuses.find((s) => Number(s) === opt.id))
|
|
||||||
.map((o) => ({
|
|
||||||
label: o.name,
|
|
||||||
value: o.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setDefaultDataValue(statusData);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadDefaultStatus();
|
|
||||||
}, [defaultValue, options]);
|
|
||||||
|
|
||||||
const loadStatusOptions = async () => {
|
|
||||||
return options
|
|
||||||
.map((result) => ({
|
|
||||||
label: result.name,
|
|
||||||
value: result.id,
|
|
||||||
}))
|
|
||||||
.filter(({ label }) => label.toLowerCase());
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AsyncSelect
|
|
||||||
key={`status-select-${defaultDataValue}`}
|
|
||||||
className="react-select-container"
|
|
||||||
classNamePrefix="react-select"
|
|
||||||
defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]}
|
|
||||||
defaultOptions
|
|
||||||
isMulti={isMulti}
|
|
||||||
loadOptions={loadStatusOptions}
|
|
||||||
placeholder={intl.formatMessage(messages.searchStatus)}
|
|
||||||
onChange={(value) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
onChange(value as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const KeywordSelector = ({
|
export const KeywordSelector = ({
|
||||||
isMulti,
|
isMulti,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
@@ -452,7 +376,6 @@ export const WatchProviderSelector = ({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
||||||
alt=""
|
alt=""
|
||||||
style={{
|
style={{
|
||||||
@@ -498,7 +421,6 @@ export const WatchProviderSelector = ({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
|
||||||
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
||||||
alt=""
|
alt=""
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import Button from '@app/components/Common/Button';
|
|||||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||||
import LibraryItem from '@app/components/Settings/LibraryItem';
|
import LibraryItem from '@app/components/Settings/LibraryItem';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
||||||
import { ApiErrorCode } from '@server/constants/error';
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
|
||||||
import type { JellyfinSettings } from '@server/lib/settings';
|
import type { JellyfinSettings } from '@server/lib/settings';
|
||||||
import { Field, Formik } from 'formik';
|
import { Field, Formik } from 'formik';
|
||||||
|
import getConfig from 'next/config';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FormattedMessage, useIntl } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
@@ -62,9 +61,6 @@ const messages = defineMessages('components.Settings', {
|
|||||||
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||||
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
|
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
|
||||||
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
|
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
|
||||||
tip: 'Tip',
|
|
||||||
scanbackground:
|
|
||||||
'Scanning will run in the background. You can continue the setup process in the meantime.',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
interface Library {
|
interface Library {
|
||||||
@@ -82,13 +78,13 @@ interface SyncStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsJellyfinProps {
|
interface SettingsJellyfinProps {
|
||||||
isSetupSettings?: boolean;
|
showAdvancedSettings?: boolean;
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||||
onComplete,
|
onComplete,
|
||||||
isSetupSettings,
|
showAdvancedSettings,
|
||||||
}) => {
|
}) => {
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
const toasts = useToasts();
|
const toasts = useToasts();
|
||||||
@@ -106,7 +102,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
);
|
);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
const settings = useSettings();
|
const { publicRuntimeConfig } = getConfig();
|
||||||
|
|
||||||
const JellyfinSettingsSchema = Yup.object().shape({
|
const JellyfinSettingsSchema = Yup.object().shape({
|
||||||
hostname: Yup.string()
|
hostname: Yup.string()
|
||||||
@@ -288,29 +284,26 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
return <LoadingSpinner />;
|
return <LoadingSpinner />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaServerFormatValues = {
|
|
||||||
mediaServerName:
|
|
||||||
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
|
||||||
? 'Jellyfin'
|
|
||||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
|
||||||
? 'Emby'
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h3 className="heading">
|
<h3 className="heading">
|
||||||
{intl.formatMessage(
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
messages.jellyfinlibraries,
|
? intl.formatMessage(messages.jellyfinlibraries, {
|
||||||
mediaServerFormatValues
|
mediaServerName: 'Emby',
|
||||||
)}
|
})
|
||||||
|
: intl.formatMessage(messages.jellyfinlibraries, {
|
||||||
|
mediaServerName: 'Jellyfin',
|
||||||
|
})}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="description">
|
<p className="description">
|
||||||
{intl.formatMessage(
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
messages.jellyfinlibrariesDescription,
|
? intl.formatMessage(messages.jellyfinlibrariesDescription, {
|
||||||
mediaServerFormatValues
|
mediaServerName: 'Emby',
|
||||||
)}
|
})
|
||||||
|
: intl.formatMessage(messages.jellyfinlibrariesDescription, {
|
||||||
|
mediaServerName: 'Jellyfin',
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="section">
|
<div className="section">
|
||||||
@@ -347,10 +340,13 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
<FormattedMessage {...messages.manualscanJellyfin} />
|
<FormattedMessage {...messages.manualscanJellyfin} />
|
||||||
</h3>
|
</h3>
|
||||||
<p className="description">
|
<p className="description">
|
||||||
{intl.formatMessage(
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
messages.manualscanDescriptionJellyfin,
|
? intl.formatMessage(messages.manualscanDescriptionJellyfin, {
|
||||||
mediaServerFormatValues
|
mediaServerName: 'Emby',
|
||||||
)}
|
})
|
||||||
|
: intl.formatMessage(messages.manualscanDescriptionJellyfin, {
|
||||||
|
mediaServerName: 'Jellyfin',
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="section">
|
<div className="section">
|
||||||
@@ -450,26 +446,24 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isSetupSettings && (
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
<span className="mr-2">
|
|
||||||
<Badge>{intl.formatMessage(messages.tip)}</Badge>
|
|
||||||
</span>
|
|
||||||
{intl.formatMessage(messages.scanbackground)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mt-10 mb-6">
|
<div className="mt-10 mb-6">
|
||||||
<h3 className="heading">
|
<h3 className="heading">
|
||||||
{intl.formatMessage(
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
messages.jellyfinSettings,
|
? intl.formatMessage(messages.jellyfinSettings, {
|
||||||
mediaServerFormatValues
|
mediaServerName: 'Emby',
|
||||||
)}
|
})
|
||||||
|
: intl.formatMessage(messages.jellyfinSettings, {
|
||||||
|
mediaServerName: 'Jellyfin',
|
||||||
|
})}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="description">
|
<p className="description">
|
||||||
{intl.formatMessage(
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
messages.jellyfinSettingsDescription,
|
? intl.formatMessage(messages.jellyfinSettingsDescription, {
|
||||||
mediaServerFormatValues
|
mediaServerName: 'Emby',
|
||||||
)}
|
})
|
||||||
|
: intl.formatMessage(messages.jellyfinSettingsDescription, {
|
||||||
|
mediaServerName: 'Jellyfin',
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Formik
|
<Formik
|
||||||
@@ -503,10 +497,12 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
intl.formatMessage(
|
intl.formatMessage(messages.jellyfinSettingsSuccess, {
|
||||||
messages.jellyfinSettingsSuccess,
|
mediaServerName:
|
||||||
mediaServerFormatValues
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
),
|
? 'Emby'
|
||||||
|
: 'Jellyfin',
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
@@ -522,10 +518,12 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
}
|
}
|
||||||
if (errorData?.message === ApiErrorCode.InvalidUrl) {
|
if (errorData?.message === ApiErrorCode.InvalidUrl) {
|
||||||
addToast(
|
addToast(
|
||||||
intl.formatMessage(
|
intl.formatMessage(messages.invalidurlerror, {
|
||||||
messages.invalidurlerror,
|
mediaServerName:
|
||||||
mediaServerFormatValues
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
),
|
? 'Emby'
|
||||||
|
: 'Jellyfin',
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
@@ -533,10 +531,12 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
addToast(
|
addToast(
|
||||||
intl.formatMessage(
|
intl.formatMessage(messages.jellyfinSettingsFailure, {
|
||||||
messages.jellyfinSettingsFailure,
|
mediaServerName:
|
||||||
mediaServerFormatValues
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
),
|
? 'Emby'
|
||||||
|
: 'Jellyfin',
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
@@ -559,7 +559,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<form className="section" onSubmit={handleSubmit}>
|
<form className="section" onSubmit={handleSubmit}>
|
||||||
{!isSetupSettings && (
|
{showAdvancedSettings && (
|
||||||
<>
|
<>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="hostname" className="text-label">
|
<label htmlFor="hostname" className="text-label">
|
||||||
@@ -643,7 +643,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isSetupSettings && (
|
{showAdvancedSettings && (
|
||||||
<>
|
<>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="urlBase" className="text-label">
|
<label htmlFor="urlBase" className="text-label">
|
||||||
@@ -710,9 +710,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="actions">
|
||||||
className={`actions ${isSetupSettings ? 'mt-0 border-0' : ''}`}
|
|
||||||
>
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import PageTitle from '@app/components/Common/PageTitle';
|
|||||||
import Table from '@app/components/Common/Table';
|
import Table from '@app/components/Common/Table';
|
||||||
import useLocale from '@app/hooks/useLocale';
|
import useLocale from '@app/hooks/useLocale';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
|
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { formatBytes } from '@app/utils/numberHelpers';
|
import { formatBytes } from '@app/utils/numberHelpers';
|
||||||
@@ -58,8 +57,8 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
|
|||||||
'plex-recently-added-scan': 'Plex Recently Added Scan',
|
'plex-recently-added-scan': 'Plex Recently Added Scan',
|
||||||
'plex-full-scan': 'Plex Full Library Scan',
|
'plex-full-scan': 'Plex Full Library Scan',
|
||||||
'plex-watchlist-sync': 'Plex Watchlist Sync',
|
'plex-watchlist-sync': 'Plex Watchlist Sync',
|
||||||
'jellyfin-full-scan': 'Jellyfin Full Library Scan',
|
|
||||||
'jellyfin-recently-added-scan': 'Jellyfin Recently Added Scan',
|
'jellyfin-recently-added-scan': 'Jellyfin Recently Added Scan',
|
||||||
|
'jellyfin-full-scan': 'Jellyfin Full Library Scan',
|
||||||
'availability-sync': 'Media Availability Sync',
|
'availability-sync': 'Media Availability Sync',
|
||||||
'radarr-scan': 'Radarr Scan',
|
'radarr-scan': 'Radarr Scan',
|
||||||
'sonarr-scan': 'Sonarr Scan',
|
'sonarr-scan': 'Sonarr Scan',
|
||||||
@@ -82,7 +81,6 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
|
|||||||
'When enabled in settings, Jellyseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
|
'When enabled in settings, Jellyseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
|
||||||
imagecachecount: 'Images Cached',
|
imagecachecount: 'Images Cached',
|
||||||
imagecachesize: 'Total Cache Size',
|
imagecachesize: 'Total Cache Size',
|
||||||
usersavatars: "Users' Avatars",
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -169,20 +167,6 @@ const SettingsJobs = () => {
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
|
||||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
|
||||||
messages['jellyfin-recently-added-scan'] = {
|
|
||||||
id: 'jellyfin-recently-added-scan',
|
|
||||||
defaultMessage: 'Emby Recently Added Scan',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
|
||||||
messages['jellyfin-full-scan'] = {
|
|
||||||
id: 'jellyfin-full-scan',
|
|
||||||
defaultMessage: 'Emby Full Library Scan',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data && !error) {
|
if (!data && !error) {
|
||||||
return <LoadingSpinner />;
|
return <LoadingSpinner />;
|
||||||
}
|
}
|
||||||
@@ -574,19 +558,6 @@ const SettingsJobs = () => {
|
|||||||
{formatBytes(cacheData?.imageCache.tmdb.size ?? 0)}
|
{formatBytes(cacheData?.imageCache.tmdb.size ?? 0)}
|
||||||
</Table.TD>
|
</Table.TD>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<Table.TD>
|
|
||||||
{intl.formatMessage(messages.usersavatars)} (avatar)
|
|
||||||
</Table.TD>
|
|
||||||
<Table.TD>
|
|
||||||
{intl.formatNumber(
|
|
||||||
cacheData?.imageCache.avatar.imageCount ?? 0
|
|
||||||
)}
|
|
||||||
</Table.TD>
|
|
||||||
<Table.TD>
|
|
||||||
{formatBytes(cacheData?.imageCache.avatar.size ?? 0)}
|
|
||||||
</Table.TD>
|
|
||||||
</tr>
|
|
||||||
</Table.TBody>
|
</Table.TBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import useSettings from '@app/hooks/useSettings';
|
|||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import getConfig from 'next/config';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages('components.Settings', {
|
const messages = defineMessages('components.Settings', {
|
||||||
@@ -25,6 +26,7 @@ type SettingsLayoutProps = {
|
|||||||
|
|
||||||
const SettingsLayout = ({ children }: SettingsLayoutProps) => {
|
const SettingsLayout = ({ children }: SettingsLayoutProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const { publicRuntimeConfig } = getConfig();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const settingsRoutes: SettingsRoute[] = [
|
const settingsRoutes: SettingsRoute[] = [
|
||||||
{
|
{
|
||||||
@@ -87,11 +89,7 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => {
|
|||||||
function getAvailableMediaServerName() {
|
function getAvailableMediaServerName() {
|
||||||
return intl.formatMessage(messages.menuJellyfinSettings, {
|
return intl.formatMessage(messages.menuJellyfinSettings, {
|
||||||
mediaServerName:
|
mediaServerName:
|
||||||
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
publicRuntimeConfig.JELLYFIN_TYPE === 'emby' ? 'Emby' : 'Jellyfin',
|
||||||
? 'Jellyfin'
|
|
||||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
|
||||||
? 'Emby'
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,17 +55,6 @@ const messages = defineMessages('components.Settings.SettingsMain', {
|
|||||||
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||||
partialRequestsEnabled: 'Allow Partial Series Requests',
|
partialRequestsEnabled: 'Allow Partial Series Requests',
|
||||||
locale: 'Display Language',
|
locale: 'Display Language',
|
||||||
proxyEnabled: 'HTTP(S) Proxy',
|
|
||||||
proxyHostname: 'Proxy Hostname',
|
|
||||||
proxyPort: 'Proxy Port',
|
|
||||||
proxySsl: 'Use SSL For Proxy',
|
|
||||||
proxyUser: 'Proxy Username',
|
|
||||||
proxyPassword: 'Proxy Password',
|
|
||||||
proxyBypassFilter: 'Proxy Ignored Addresses',
|
|
||||||
proxyBypassFilterTip:
|
|
||||||
"Use ',' as a separator, and '*.' as a wildcard for subdomains",
|
|
||||||
proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses',
|
|
||||||
validationProxyPort: 'You must provide a valid port',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const SettingsMain = () => {
|
const SettingsMain = () => {
|
||||||
@@ -93,12 +82,6 @@ const SettingsMain = () => {
|
|||||||
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
||||||
(value) => !value || !value.endsWith('/')
|
(value) => !value || !value.endsWith('/')
|
||||||
),
|
),
|
||||||
proxyPort: Yup.number().when('proxyEnabled', {
|
|
||||||
is: (proxyEnabled: boolean) => proxyEnabled,
|
|
||||||
then: Yup.number().required(
|
|
||||||
intl.formatMessage(messages.validationProxyPort)
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const regenerate = async () => {
|
const regenerate = async () => {
|
||||||
@@ -154,14 +137,6 @@ const SettingsMain = () => {
|
|||||||
partialRequestsEnabled: data?.partialRequestsEnabled,
|
partialRequestsEnabled: data?.partialRequestsEnabled,
|
||||||
trustProxy: data?.trustProxy,
|
trustProxy: data?.trustProxy,
|
||||||
cacheImages: data?.cacheImages,
|
cacheImages: data?.cacheImages,
|
||||||
proxyEnabled: data?.proxy?.enabled,
|
|
||||||
proxyHostname: data?.proxy?.hostname,
|
|
||||||
proxyPort: data?.proxy?.port,
|
|
||||||
proxySsl: data?.proxy?.useSsl,
|
|
||||||
proxyUser: data?.proxy?.user,
|
|
||||||
proxyPassword: data?.proxy?.password,
|
|
||||||
proxyBypassFilter: data?.proxy?.bypassFilter,
|
|
||||||
proxyBypassLocalAddresses: data?.proxy?.bypassLocalAddresses,
|
|
||||||
}}
|
}}
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
validationSchema={MainSettingsSchema}
|
validationSchema={MainSettingsSchema}
|
||||||
@@ -183,16 +158,6 @@ const SettingsMain = () => {
|
|||||||
partialRequestsEnabled: values.partialRequestsEnabled,
|
partialRequestsEnabled: values.partialRequestsEnabled,
|
||||||
trustProxy: values.trustProxy,
|
trustProxy: values.trustProxy,
|
||||||
cacheImages: values.cacheImages,
|
cacheImages: values.cacheImages,
|
||||||
proxy: {
|
|
||||||
enabled: values.proxyEnabled,
|
|
||||||
hostname: values.proxyHostname,
|
|
||||||
port: values.proxyPort,
|
|
||||||
useSsl: values.proxySsl,
|
|
||||||
user: values.proxyUser,
|
|
||||||
password: values.proxyPassword,
|
|
||||||
bypassFilter: values.proxyBypassFilter,
|
|
||||||
bypassLocalAddresses: values.proxyBypassLocalAddresses,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
@@ -472,176 +437,6 @@ const SettingsMain = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
|
||||||
<label htmlFor="proxyEnabled" className="checkbox-label">
|
|
||||||
<span className="mr-2">
|
|
||||||
{intl.formatMessage(messages.proxyEnabled)}
|
|
||||||
</span>
|
|
||||||
<SettingsBadge badgeType="advanced" className="mr-2" />
|
|
||||||
<SettingsBadge badgeType="restartRequired" />
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<Field
|
|
||||||
type="checkbox"
|
|
||||||
id="proxyEnabled"
|
|
||||||
name="proxyEnabled"
|
|
||||||
onChange={() => {
|
|
||||||
setFieldValue('proxyEnabled', !values.proxyEnabled);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{values.proxyEnabled && (
|
|
||||||
<>
|
|
||||||
<div className="form-row">
|
|
||||||
<label htmlFor="proxyHostname" className="checkbox-label">
|
|
||||||
<span className="mr-2 ml-4">
|
|
||||||
{intl.formatMessage(messages.proxyHostname)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<div className="form-input-field">
|
|
||||||
<Field
|
|
||||||
id="proxyHostname"
|
|
||||||
name="proxyHostname"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.proxyHostname &&
|
|
||||||
touched.proxyHostname &&
|
|
||||||
typeof errors.proxyHostname === 'string' && (
|
|
||||||
<div className="error">{errors.proxyHostname}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label htmlFor="proxyPort" className="checkbox-label">
|
|
||||||
<span className="mr-2 ml-4">
|
|
||||||
{intl.formatMessage(messages.proxyPort)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<div className="form-input-field">
|
|
||||||
<Field id="proxyPort" name="proxyPort" type="text" />
|
|
||||||
</div>
|
|
||||||
{errors.proxyPort &&
|
|
||||||
touched.proxyPort &&
|
|
||||||
typeof errors.proxyPort === 'string' && (
|
|
||||||
<div className="error">{errors.proxyPort}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label htmlFor="proxySsl" className="checkbox-label">
|
|
||||||
<span className="mr-2 ml-4">
|
|
||||||
{intl.formatMessage(messages.proxySsl)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<Field
|
|
||||||
type="checkbox"
|
|
||||||
id="proxySsl"
|
|
||||||
name="proxySsl"
|
|
||||||
onChange={() => {
|
|
||||||
setFieldValue('proxySsl', !values.proxySsl);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label htmlFor="proxyUser" className="checkbox-label">
|
|
||||||
<span className="mr-2 ml-4">
|
|
||||||
{intl.formatMessage(messages.proxyUser)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<div className="form-input-field">
|
|
||||||
<Field id="proxyUser" name="proxyUser" type="text" />
|
|
||||||
</div>
|
|
||||||
{errors.proxyUser &&
|
|
||||||
touched.proxyUser &&
|
|
||||||
typeof errors.proxyUser === 'string' && (
|
|
||||||
<div className="error">{errors.proxyUser}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label htmlFor="proxyPassword" className="checkbox-label">
|
|
||||||
<span className="mr-2 ml-4">
|
|
||||||
{intl.formatMessage(messages.proxyPassword)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<div className="form-input-field">
|
|
||||||
<Field
|
|
||||||
id="proxyPassword"
|
|
||||||
name="proxyPassword"
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.proxyPassword &&
|
|
||||||
touched.proxyPassword &&
|
|
||||||
typeof errors.proxyPassword === 'string' && (
|
|
||||||
<div className="error">{errors.proxyPassword}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label
|
|
||||||
htmlFor="proxyBypassFilter"
|
|
||||||
className="checkbox-label"
|
|
||||||
>
|
|
||||||
<span className="mr-2 ml-4">
|
|
||||||
{intl.formatMessage(messages.proxyBypassFilter)}
|
|
||||||
</span>
|
|
||||||
<span className="label-tip ml-4">
|
|
||||||
{intl.formatMessage(messages.proxyBypassFilterTip)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<div className="form-input-field">
|
|
||||||
<Field
|
|
||||||
id="proxyBypassFilter"
|
|
||||||
name="proxyBypassFilter"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.proxyBypassFilter &&
|
|
||||||
touched.proxyBypassFilter &&
|
|
||||||
typeof errors.proxyBypassFilter === 'string' && (
|
|
||||||
<div className="error">
|
|
||||||
{errors.proxyBypassFilter}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label
|
|
||||||
htmlFor="proxyBypassLocalAddresses"
|
|
||||||
className="checkbox-label"
|
|
||||||
>
|
|
||||||
<span className="mr-2 ml-4">
|
|
||||||
{intl.formatMessage(
|
|
||||||
messages.proxyBypassLocalAddresses
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<Field
|
|
||||||
type="checkbox"
|
|
||||||
id="proxyBypassLocalAddresses"
|
|
||||||
name="proxyBypassLocalAddresses"
|
|
||||||
onChange={() => {
|
|
||||||
setFieldValue(
|
|
||||||
'proxyBypassLocalAddresses',
|
|
||||||
!values.proxyBypassLocalAddresses
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
|||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import type { MainSettings } from '@server/lib/settings';
|
import type { MainSettings } from '@server/lib/settings';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
import getConfig from 'next/config';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR, { mutate } from 'swr';
|
import useSWR, { mutate } from 'swr';
|
||||||
@@ -41,20 +42,12 @@ const SettingsUsers = () => {
|
|||||||
mutate: revalidate,
|
mutate: revalidate,
|
||||||
} = useSWR<MainSettings>('/api/v1/settings/main');
|
} = useSWR<MainSettings>('/api/v1/settings/main');
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
const { publicRuntimeConfig } = getConfig();
|
||||||
|
|
||||||
if (!data && !error) {
|
if (!data && !error) {
|
||||||
return <LoadingSpinner />;
|
return <LoadingSpinner />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaServerFormatValues = {
|
|
||||||
mediaServerName:
|
|
||||||
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
|
||||||
? 'Jellyfin'
|
|
||||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
|
||||||
? 'Emby'
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -128,10 +121,16 @@ const SettingsUsers = () => {
|
|||||||
<label htmlFor="localLogin" className="checkbox-label">
|
<label htmlFor="localLogin" className="checkbox-label">
|
||||||
{intl.formatMessage(messages.localLogin)}
|
{intl.formatMessage(messages.localLogin)}
|
||||||
<span className="label-tip">
|
<span className="label-tip">
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(messages.localLoginTip, {
|
||||||
messages.localLoginTip,
|
mediaServerName:
|
||||||
mediaServerFormatValues
|
settings.currentSettings.mediaServerType ===
|
||||||
)}
|
MediaServerType.PLEX
|
||||||
|
? 'Plex'
|
||||||
|
: settings.currentSettings.mediaServerType ===
|
||||||
|
MediaServerType.JELLYFIN
|
||||||
|
? 'Jellyfin'
|
||||||
|
: 'Emby',
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
@@ -147,15 +146,25 @@ const SettingsUsers = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="newPlexLogin" className="checkbox-label">
|
<label htmlFor="newPlexLogin" className="checkbox-label">
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(messages.newPlexLogin, {
|
||||||
messages.newPlexLogin,
|
mediaServerName:
|
||||||
mediaServerFormatValues
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
)}
|
? 'Emby'
|
||||||
|
: settings.currentSettings.mediaServerType ===
|
||||||
|
MediaServerType.PLEX
|
||||||
|
? 'Plex'
|
||||||
|
: 'Jellyfin',
|
||||||
|
})}
|
||||||
<span className="label-tip">
|
<span className="label-tip">
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(messages.newPlexLoginTip, {
|
||||||
messages.newPlexLoginTip,
|
mediaServerName:
|
||||||
mediaServerFormatValues
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
)}
|
? 'Emby'
|
||||||
|
: settings.currentSettings.mediaServerType ===
|
||||||
|
MediaServerType.PLEX
|
||||||
|
? 'Plex'
|
||||||
|
: 'Jellyfin',
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
|
|||||||
@@ -86,12 +86,10 @@ interface TestResponse {
|
|||||||
id: number;
|
id: number;
|
||||||
path: string;
|
path: string;
|
||||||
}[];
|
}[];
|
||||||
languageProfiles:
|
languageProfiles: {
|
||||||
| {
|
id: number;
|
||||||
id: number;
|
name: string;
|
||||||
name: string;
|
}[];
|
||||||
}[]
|
|
||||||
| null;
|
|
||||||
tags: {
|
tags: {
|
||||||
id: number;
|
id: number;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -114,7 +112,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
const [testResponse, setTestResponse] = useState<TestResponse>({
|
const [testResponse, setTestResponse] = useState<TestResponse>({
|
||||||
profiles: [],
|
profiles: [],
|
||||||
rootFolders: [],
|
rootFolders: [],
|
||||||
languageProfiles: null,
|
languageProfiles: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
});
|
});
|
||||||
const SonarrSettingsSchema = Yup.object().shape({
|
const SonarrSettingsSchema = Yup.object().shape({
|
||||||
@@ -139,11 +137,9 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
activeProfileId: Yup.string().required(
|
activeProfileId: Yup.string().required(
|
||||||
intl.formatMessage(messages.validationProfileRequired)
|
intl.formatMessage(messages.validationProfileRequired)
|
||||||
),
|
),
|
||||||
activeLanguageProfileId: testResponse.languageProfiles
|
activeLanguageProfileId: Yup.number().required(
|
||||||
? Yup.number().required(
|
intl.formatMessage(messages.validationLanguageProfileRequired)
|
||||||
intl.formatMessage(messages.validationLanguageProfileRequired)
|
),
|
||||||
)
|
|
||||||
: Yup.number(),
|
|
||||||
externalUrl: Yup.string()
|
externalUrl: Yup.string()
|
||||||
.url(intl.formatMessage(messages.validationApplicationUrl))
|
.url(intl.formatMessage(messages.validationApplicationUrl))
|
||||||
.test(
|
.test(
|
||||||
@@ -662,56 +658,54 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{testResponse.languageProfiles && (
|
<div className="form-row">
|
||||||
<div className="form-row">
|
<label
|
||||||
<label
|
htmlFor="activeLanguageProfileId"
|
||||||
htmlFor="activeLanguageProfileId"
|
className="text-label"
|
||||||
className="text-label"
|
>
|
||||||
>
|
{intl.formatMessage(messages.languageprofile)}
|
||||||
{intl.formatMessage(messages.languageprofile)}
|
<span className="label-required">*</span>
|
||||||
<span className="label-required">*</span>
|
</label>
|
||||||
</label>
|
<div className="form-input-area">
|
||||||
<div className="form-input-area">
|
<div className="form-input-field">
|
||||||
<div className="form-input-field">
|
<Field
|
||||||
<Field
|
as="select"
|
||||||
as="select"
|
id="activeLanguageProfileId"
|
||||||
id="activeLanguageProfileId"
|
name="activeLanguageProfileId"
|
||||||
name="activeLanguageProfileId"
|
disabled={!isValidated || isTesting}
|
||||||
disabled={!isValidated || isTesting}
|
>
|
||||||
>
|
<option value="">
|
||||||
<option value="">
|
{isTesting
|
||||||
{isTesting
|
? intl.formatMessage(
|
||||||
? intl.formatMessage(
|
messages.loadinglanguageprofiles
|
||||||
messages.loadinglanguageprofiles
|
)
|
||||||
)
|
: !isValidated
|
||||||
: !isValidated
|
? intl.formatMessage(
|
||||||
? intl.formatMessage(
|
messages.testFirstLanguageProfiles
|
||||||
messages.testFirstLanguageProfiles
|
)
|
||||||
)
|
: intl.formatMessage(
|
||||||
: intl.formatMessage(
|
messages.selectLanguageProfile
|
||||||
messages.selectLanguageProfile
|
)}
|
||||||
)}
|
</option>
|
||||||
</option>
|
{testResponse.languageProfiles.length > 0 &&
|
||||||
{testResponse.languageProfiles.length > 0 &&
|
testResponse.languageProfiles.map((language) => (
|
||||||
testResponse.languageProfiles.map((language) => (
|
<option
|
||||||
<option
|
key={`loaded-profile-${language.id}`}
|
||||||
key={`loaded-profile-${language.id}`}
|
value={language.id}
|
||||||
value={language.id}
|
>
|
||||||
>
|
{language.name}
|
||||||
{language.name}
|
</option>
|
||||||
</option>
|
))}
|
||||||
))}
|
</Field>
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
{errors.activeLanguageProfileId &&
|
|
||||||
touched.activeLanguageProfileId && (
|
|
||||||
<div className="error">
|
|
||||||
{errors.activeLanguageProfileId}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{errors.activeLanguageProfileId &&
|
||||||
|
touched.activeLanguageProfileId && (
|
||||||
|
<div className="error">
|
||||||
|
{errors.activeLanguageProfileId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="tags" className="text-label">
|
<label htmlFor="tags" className="text-label">
|
||||||
{intl.formatMessage(messages.tags)}
|
{intl.formatMessage(messages.tags)}
|
||||||
@@ -869,55 +863,53 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{testResponse.languageProfiles && (
|
<div className="form-row">
|
||||||
<div className="form-row">
|
<label
|
||||||
<label
|
htmlFor="activeAnimeLanguageProfileId"
|
||||||
htmlFor="activeAnimeLanguageProfileId"
|
className="text-label"
|
||||||
className="text-label"
|
>
|
||||||
>
|
{intl.formatMessage(messages.animelanguageprofile)}
|
||||||
{intl.formatMessage(messages.animelanguageprofile)}
|
</label>
|
||||||
</label>
|
<div className="form-input-area">
|
||||||
<div className="form-input-area">
|
<div className="form-input-field">
|
||||||
<div className="form-input-field">
|
<Field
|
||||||
<Field
|
as="select"
|
||||||
as="select"
|
id="activeAnimeLanguageProfileId"
|
||||||
id="activeAnimeLanguageProfileId"
|
name="activeAnimeLanguageProfileId"
|
||||||
name="activeAnimeLanguageProfileId"
|
disabled={!isValidated || isTesting}
|
||||||
disabled={!isValidated || isTesting}
|
>
|
||||||
>
|
<option value="">
|
||||||
<option value="">
|
{isTesting
|
||||||
{isTesting
|
? intl.formatMessage(
|
||||||
? intl.formatMessage(
|
messages.loadinglanguageprofiles
|
||||||
messages.loadinglanguageprofiles
|
)
|
||||||
)
|
: !isValidated
|
||||||
: !isValidated
|
? intl.formatMessage(
|
||||||
? intl.formatMessage(
|
messages.testFirstLanguageProfiles
|
||||||
messages.testFirstLanguageProfiles
|
)
|
||||||
)
|
: intl.formatMessage(
|
||||||
: intl.formatMessage(
|
messages.selectLanguageProfile
|
||||||
messages.selectLanguageProfile
|
)}
|
||||||
)}
|
</option>
|
||||||
</option>
|
{testResponse.languageProfiles.length > 0 &&
|
||||||
{testResponse.languageProfiles.length > 0 &&
|
testResponse.languageProfiles.map((language) => (
|
||||||
testResponse.languageProfiles.map((language) => (
|
<option
|
||||||
<option
|
key={`loaded-profile-${language.id}`}
|
||||||
key={`loaded-profile-${language.id}`}
|
value={language.id}
|
||||||
value={language.id}
|
>
|
||||||
>
|
{language.name}
|
||||||
{language.name}
|
</option>
|
||||||
</option>
|
))}
|
||||||
))}
|
</Field>
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
{errors.activeAnimeLanguageProfileId &&
|
|
||||||
touched.activeAnimeLanguageProfileId && (
|
|
||||||
<div className="error">
|
|
||||||
{errors.activeAnimeLanguageProfileId}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{errors.activeAnimeLanguageProfileId &&
|
||||||
|
touched.activeAnimeLanguageProfileId && (
|
||||||
|
<div className="error">
|
||||||
|
{errors.activeAnimeLanguageProfileId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="tags" className="text-label">
|
<label htmlFor="tags" className="text-label">
|
||||||
{intl.formatMessage(messages.animeTags)}
|
{intl.formatMessage(messages.animeTags)}
|
||||||
|
|||||||
@@ -1,39 +1,32 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
import Accordion from '@app/components/Common/Accordion';
|
||||||
import JellyfinLogin from '@app/components/Login/JellyfinLogin';
|
import JellyfinLogin from '@app/components/Login/JellyfinLogin';
|
||||||
import PlexLoginButton from '@app/components/PlexLoginButton';
|
import PlexLoginButton from '@app/components/PlexLoginButton';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import getConfig from 'next/config';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages('components.Setup', {
|
const messages = defineMessages('components.Setup', {
|
||||||
welcome: 'Welcome to Jellyseerr',
|
welcome: 'Welcome to Jellyseerr',
|
||||||
signinMessage: 'Get started by signing in',
|
signinMessage: 'Get started by signing in',
|
||||||
signin: 'Sign in to your account',
|
signinWithJellyfin: 'Use your {mediaServerName} account',
|
||||||
signinWithJellyfin: 'Enter your Jellyfin details',
|
signinWithPlex: 'Use your Plex account',
|
||||||
signinWithEmby: 'Enter your Emby details',
|
|
||||||
signinWithPlex: 'Enter your Plex details',
|
|
||||||
back: 'Go back',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
interface LoginWithMediaServerProps {
|
interface LoginWithMediaServerProps {
|
||||||
serverType: MediaServerType;
|
onComplete: (onComplete: MediaServerType) => void;
|
||||||
onCancel: () => void;
|
|
||||||
onComplete: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
|
const SetupLogin: React.FC<LoginWithMediaServerProps> = ({ onComplete }) => {
|
||||||
serverType,
|
|
||||||
onCancel,
|
|
||||||
onComplete,
|
|
||||||
}) => {
|
|
||||||
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
|
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
|
||||||
const [mediaServerType, setMediaServerType] = useState<MediaServerType>(
|
const [mediaServerType, setMediaServerType] = useState<MediaServerType>(
|
||||||
MediaServerType.NOT_CONFIGURED
|
MediaServerType.NOT_CONFIGURED
|
||||||
);
|
);
|
||||||
const { user, revalidate } = useUser();
|
const { user, revalidate } = useUser();
|
||||||
|
const intl = useIntl();
|
||||||
|
const { publicRuntimeConfig } = getConfig();
|
||||||
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
|
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
|
||||||
// We take the token and attempt to login. If we get a success message, we will
|
// We take the token and attempt to login. If we get a success message, we will
|
||||||
// ask swr to revalidate the user which _shouid_ come back with a valid user.
|
// ask swr to revalidate the user which _shouid_ come back with a valid user.
|
||||||
@@ -63,60 +56,71 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
onComplete();
|
onComplete(mediaServerType);
|
||||||
}
|
}
|
||||||
}, [user, mediaServerType, onComplete]);
|
}, [user, mediaServerType, onComplete]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div>
|
||||||
<div className="mb-2 flex justify-center text-xl font-bold">
|
<div className="mb-2 flex justify-center text-xl font-bold">
|
||||||
<FormattedMessage {...messages.signin} />
|
<FormattedMessage {...messages.welcome} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 flex justify-center pb-6 text-sm">
|
<div className="mb-2 flex justify-center pb-6 text-sm">
|
||||||
{serverType === MediaServerType.JELLYFIN ? (
|
<FormattedMessage {...messages.signinMessage} />
|
||||||
<FormattedMessage {...messages.signinWithJellyfin} />
|
|
||||||
) : serverType === MediaServerType.EMBY ? (
|
|
||||||
<FormattedMessage {...messages.signinWithEmby} />
|
|
||||||
) : (
|
|
||||||
<FormattedMessage {...messages.signinWithPlex} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{serverType === MediaServerType.PLEX && (
|
<Accordion single atLeastOne>
|
||||||
<>
|
{({ openIndexes, handleClick, AccordionContent }) => (
|
||||||
<div
|
<>
|
||||||
className="px-10 py-8"
|
<button
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
|
className={`w-full cursor-default bg-gray-900 py-2 text-center text-sm text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none sm:rounded-t-lg ${
|
||||||
>
|
openIndexes.includes(0) && 'text-indigo-500'
|
||||||
<PlexLoginButton
|
} ${openIndexes.includes(1) && 'border-b border-gray-500'}`}
|
||||||
onAuthToken={(authToken) => {
|
onClick={() => handleClick(0)}
|
||||||
setMediaServerType(MediaServerType.PLEX);
|
>
|
||||||
setAuthToken(authToken);
|
<FormattedMessage {...messages.signinWithPlex} />
|
||||||
}}
|
</button>
|
||||||
/>
|
<AccordionContent isOpen={openIndexes.includes(0)}>
|
||||||
</div>
|
<div
|
||||||
<div className="mt-4">
|
className="px-10 py-8"
|
||||||
<Button buttonType="default" onClick={() => onCancel()}>
|
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
|
||||||
<FormattedMessage {...messages.back} />
|
>
|
||||||
</Button>
|
<PlexLoginButton
|
||||||
</div>
|
onAuthToken={(authToken) => {
|
||||||
</>
|
setMediaServerType(MediaServerType.PLEX);
|
||||||
)}
|
setAuthToken(authToken);
|
||||||
{serverType === MediaServerType.JELLYFIN && (
|
}}
|
||||||
<JellyfinLogin
|
/>
|
||||||
initial={true}
|
</div>
|
||||||
revalidate={revalidate}
|
</AccordionContent>
|
||||||
serverType={serverType}
|
<div>
|
||||||
onCancel={onCancel}
|
<button
|
||||||
/>
|
className={`w-full cursor-default bg-gray-900 py-2 text-center text-sm text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none ${
|
||||||
)}
|
openIndexes.includes(1)
|
||||||
{serverType === MediaServerType.EMBY && (
|
? 'text-indigo-500'
|
||||||
<JellyfinLogin
|
: 'sm:rounded-b-lg'
|
||||||
initial={true}
|
}`}
|
||||||
revalidate={revalidate}
|
onClick={() => handleClick(1)}
|
||||||
serverType={serverType}
|
>
|
||||||
onCancel={onCancel}
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
/>
|
? intl.formatMessage(messages.signinWithJellyfin, {
|
||||||
)}
|
mediaServerName: 'Emby',
|
||||||
|
})
|
||||||
|
: intl.formatMessage(messages.signinWithJellyfin, {
|
||||||
|
mediaServerName: 'Jellyfin',
|
||||||
|
})}
|
||||||
|
</button>
|
||||||
|
<AccordionContent isOpen={openIndexes.includes(1)}>
|
||||||
|
<div
|
||||||
|
className="rounded-b-lg px-10 py-8"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
|
||||||
|
>
|
||||||
|
<JellyfinLogin initial={true} revalidate={revalidate} />
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user