Compare commits

...

28 Commits

Author SHA1 Message Date
Gauthier
4e741051f3 fix: use undici proxy agent 2024-10-22 23:18:51 +02:00
Gauthier
daecb6b6cf feat: add a proxy option into settings 2024-10-19 00:19:23 +02:00
Gauthier
d99ae35c2e feat: add a proxy option into settings 2024-10-19 00:18:30 +02:00
Gauthier
a2b3408c9a feat: exit Jellyseerr when migration fails (#1026) 2024-10-18 18:24:29 +08:00
Fallenbagel
cbb1a74526 fix: fixes wrong avatar rendered for the modifiedBy user in request list (#1028)
This fixes an issue where when the request is modified it was showing the avatar of the requester
instead of the modifiedBy user

fix #1017
2024-10-18 06:28:42 +08:00
Fallenbagel
26c37ec067 docs(buildfromsource): remove latest/develop tabs and update instructions to support 2.0.0 (#1021)
re #1020
2024-10-17 23:12:41 +08:00
Gauthier
4e48fdf2cb fix: rewrite avatarproxy and CachedImage (#1016)
* fix: rewrite avatarproxy and CachedImage

Avatar proxy was allowing every request to be proxied, no matter the original ressource's origin or
filetype. This PR fixes it be allowing only relevant resources to be cached, i.e. Jellyfin/Emby
images and TMDB images.

fix #1012, #1013

* fix: resolve CodeQL error

* fix: resolve CodeQL error

* fix: resolve review comments

* fix: resolve review comment

* fix: resolve CodeQL error

* fix: update imageproxy path
2024-10-17 21:24:15 +08:00
Gauthier
a351264b87 fix: handle non-existent rottentomatoes rating (#1018)
This fixes a bug where some media don't have any rottentomatoes ratings.
2024-10-17 18:37:19 +08:00
allcontributors[bot]
9de304d17a docs: add M0NsTeRRR as a contributor for security (#1015)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-10-17 07:25:36 +08:00
Fallenbagel
4945b54298 fix: fetch override to attach XSRF token to fix csrfProtection issue (#1014)
During the migration from Axios to fetch, we overlooked the fact that Axios automatically handled
CSRF tokens, while fetch does not. When CSRF protection was turned on, requests were failing with an
"invalid CSRF token" error for users accessing the app even via HTTPS. This commit
overrides fetch to ensure that the CSRF token is included in all requests.

fix #1011
2024-10-17 07:25:06 +08:00
Fallenbagel
a0f80fe764 fix: use jellyfinMediaId4k for mediaUrl4k (#1006)
Fixes the issue where mediaUrl4K was still using the non-4k mediaId despite having the correct 4k Id
stored.

fix #520
2024-10-16 03:50:21 +08:00
Gauthier
92ba26207d feat: refresh monitored downloads before getting queue items (#994)
Currently, we sync with sonarr/radarr with whatever value those return. Radarr/Sonarr syncs the
activity from the download clients every few minutes. This leads to inaccurate estimated download
times, because of the refresh delay with Jellyseerr and the *arrs.

This PR fixes this by making a request to the *arrs to refresh the monitored downloads just before
we get these downloads information.

re #866
2024-10-10 11:37:08 +02:00
Gauthier
96e1d40304 fix(session): set the correct TTL for the cookie store (#992)
The time-to-live (TTL) of cookies stored in the database was incorrect because the connect-typeorm
library takes a TTL in seconds and not milliseconds, making cookies valid for ~82 years instead of
30 days.

fix #991
2024-10-02 20:59:35 +02:00
Thomas Loubiou
a5d22ba5b8 feat: allow request managers to delete data from sonarr/radarr (#644)
* feat: allow requests managers to delete media files

* fix(i18n): add missing translations

* fix(i18n): remove french translation

* refactor: use fetch API
2024-09-30 18:56:25 +02:00
Gauthier
f390da4866 fix(blacklist): add blacklist to mobile menu (#980)
* fix(blacklist): add blacklist to mobile menu

The "Blacklist" menu was only available in the desktop sidebar, not in the mobile menu.

fix #979

* fix: export translations
2024-09-25 21:25:44 +02:00
Joaquin Olivero
edfd80444c refactor: Proxy and cache avatar images (#907)
* refactor: proxy and cache user avatar images

* fix: extract keys

* fix: set avatar image URL

* fix: show the correct avatar in the list of available users in advanced request

* fix(s): set correct src URL for cached image

* fix: remove unexpired unused image when a user changes their avatar

* fix: requested changes

* refactor: use 'mime' package to detmerine file extension

* style: grammar

* refactor: checks if the default avatar is cached to avoid creating duplicates for different users

* fix: fix vulnerability

* fix: fix incomplete URL substring sanitization

* refactor: only cache avatar with http url protocol

* fix: remove log and correctly set the if statement for the cached image component

* fix: avatar images not showing on issues page

* style: formatting

---------

Co-authored-by: JoaquinOlivero <joaquin.olivero@hotmail.com>
2024-09-19 10:38:14 +08:00
Gauthier
2b05ffface chore(issuetemplate): update defaults labels of GitHub issues (#968) 2024-09-17 11:12:00 +05:00
Joaquin Olivero
818aa60aac feat: blacklist items from Discover page (#632)
* feat: blacklist media items

re #490

* feat: blacklist media items

* feat: blacklist media items

* style: formatting

* refactor: close the manage slide-over when the media item is removed from the blacklist

* fix: fix media data in the db when blacklisting an item

* refactor: refactor component to accept show boolean

* refactor: hide watchlist button in the media page when it's blacklisted. Also add a blacklist button

* style: formatting

---------

Co-authored-by: JoaquinOlivero <joaquin.olivero@hotmail.com>
2024-09-16 22:08:12 +02:00
Jonas F
ee7e91c7c9 fix: change SeriesSearch to MissingEpisodeSearch for season requests (#711)
This fix changes the behavior of how Overseerr requests series data from Sonarr. Previously, when adding new seasons to a partially available series, Overseerr would initiate a SeriesSearch, causing Sonarr to search for all monitored seasons of the series, including those already available. This behavior is now corrected by executing a MissingEpisodeSearchCommand for the specific seriesId, which aligns with the intended behavior of only searching for and adding the newly requested seasons that are not already available.

Resolves: https://github.com/Fallenbagel/jellyseerr/issues/710
2024-09-16 22:07:43 +05:00
Aidan Hilt
45ef150e36 feat: add environment variable for API key (#831)
* Added the ability to set the API key with the env var API_KEY

* Adding debug statements

* Updating

* feat: adding env var for API key

* feat: update

* fix(settings/index.ts): remove a print statement that logs the API key to the console

* Update en.json

* docs: added documentation about API_KEY environment variable

* feat: add a check to ensure API key always uses env var if provided

* feat: always check the API_KEY env var at startup

* chore: add back the gitkeeps under ./config, accidentally deleted in prev commit

* chore: revert change made to docker-compose that was accidentally committed
2024-09-16 19:05:44 +02:00
Fallenbagel
54cfeefe74 docs(readme): update the translate badge (#951) 2024-08-28 21:58:04 +05:00
Gauthier
89e0a831ec fix: add an error message to say when an email is already taken (#947)
When the email is modified in the user settings and it is already taken by someone else, a generic
message saying that something wrong happened, without saying that it is because the email is already
taken by another user. This PR adds this error message for the email.
2024-08-27 12:54:56 +02:00
Joaquin Olivero
e57d2654d1 fix: set correct user type when importing from emby (#949)
fix #948

Co-authored-by: JoaquinOlivero <joaquin.olivero@hotmail.com>
2024-08-26 22:14:46 +02:00
Gauthier
a02a0dd176 docs: fix broken anchors (#946) 2024-08-26 17:27:06 +05:00
Gauthier
7423bbbffc fix(setup): page display when homepage is loading (#940)
* fix: remove an unwanted display of the setup page while the homepage is loading

* fix: edit incorrect return type of setup page
2024-08-22 17:46:06 +05:00
Fallenbagel
32343f23a3 chore(migrations): proper rename & clean up of media server type migration (#939) 2024-08-22 14:37:41 +02:00
Fallenbagel
15cb949f1f feat: Jellyfin/Emby server type setup (#685)
* feat: add Media Server Selection to Setup Page

Introduce the ability to select the media server type on the setup page. Users can now choose their
preferred media server (e.g., Plex through the Plex sign-in or Emby/Jellyfin sign-in to select
either Emby or Jellyfin). The selected media server type is then reflected in the application
settings. This enhancement provides users with increased flexibility and customization options
during the initial setup process, eliminating the need to rely on environment variables (which
cannot be set if using platforms like snaps). Existing Emby users, who use the environment variable,
should log out and log back in after updating to set their mediaServerType to Emby.

BREAKING CHANGE: This commit deprecates the JELLYFIN_TYPE variable to identify Emby media server and
instead rely on the mediaServerType that is set in the `settings.json`. Existing environment
variable users can log out and log back in to set the mediaServerType to `3` (Emby).

* feat(api): add severType to the api

BREAKING CHANGE: This adds a serverType to the `/auth/jellyfin` which requires a serverType to be
set (`jellyfin`/`emby`)

* refactor: use enums for serverType and rename selectedservice to serverType

* refactor(auth): jellyfin/emby authentication to set MediaServerType

* fix: issue page formatMessage for 4k media

* refactor: cleaner way of handling serverType change using MediaServerType instead of strings

instead of using strings now it will use MediaServerType enums for serverType

* revert: removed conditional render of the auto-request permission

reverts the conditional render toshow the auto-request permission if the mediaServerType was set to
Plex as this should be handled in a different PR and Cypress tests should be modified
accordingly(currently cypress test would fail if this conditional check is there)

* feat: add server type step to setup

* feat: migrate existing emby setups to use emby mediaServerType

* fix: scan jobs not running when media server type is emby

* fix: emby media server type migration

* refactor: change emby logo to full logo

* style: decrease emby logo size in setup screen

* refactor: use title case for servertype i18n message

* refactor(i18n): fix a typo

* refactor: use enums instead of numbers

* fix: remove old references to JELLYFIN_TYPE environment variable

* fix: go back to the last step when refresh the setup page

* fix: move "scanning in background" tip next to the scanning section

* fix: redirect the setup page when Jellyseerr is already setup

---------

Co-authored-by: Gauthier <mail@gauthierth.fr>
2024-08-21 02:35:47 +05:00
Joaquin Olivero
cfd1bc2535 feat: adds status filter for tv shows (#796)
re #605

Co-authored-by: JoaquinOlivero <joaquin.olivero@hotmail.com>
2024-08-19 23:37:04 +05:00
103 changed files with 3351 additions and 954 deletions

View File

@@ -439,6 +439,15 @@
"contributions": [
"code"
]
},
{
"login": "M0NsTeRRR",
"name": "Ludovic Ortega",
"avatar_url": "https://avatars.githubusercontent.com/u/37785089?v=4",
"profile": "https://github.com/M0NsTeRRR",
"contributions": [
"security"
]
}
]
}

View File

@@ -1,6 +1,6 @@
name: 🐛 Bug Report
description: Report a problem
labels: ['type:bug', 'awaiting-triage']
labels: ['bug', 'awaiting triage']
body:
- type: markdown
attributes:

View File

@@ -1,6 +1,6 @@
name: ✨ Feature Request
description: Suggest an idea
labels: ['type:enhancement', 'awaiting-triage']
labels: ['enhancement', 'awaiting triage']
body:
- type: markdown
attributes:

View File

@@ -8,10 +8,10 @@
<p align="center">
<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="http://jellyseerr.borgcube.de/engage/jellyseerr/"><img src="http://jellyseerr.borgcube.de/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="http://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-47-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-48-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
**Jellyseerr** is a free and open source software application for managing requests for your media library.
@@ -146,6 +146,7 @@ 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/franciscofsales"><img src="https://avatars.githubusercontent.com/u/7977645?v=4?s=100" width="100px;" alt="Francisco Sales"/><br /><sub><b>Francisco Sales</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=franciscofsales" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/myselfolli"><img src="https://avatars.githubusercontent.com/u/37535998?v=4?s=100" width="100px;" alt="Oliver Laing"/><br /><sub><b>Oliver Laing</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=myselfolli" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/M0NsTeRRR"><img src="https://avatars.githubusercontent.com/u/37785089?v=4?s=100" width="100px;" alt="Ludovic Ortega"/><br /><sub><b>Ludovic Ortega</b></sub></a><br /><a href="#security-M0NsTeRRR" title="Security">🛡️</a></td>
</tr>
</tbody>
</table>

View File

@@ -12,49 +12,12 @@ import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
### 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/)
- [Pnpm 9.x](https://pnpm.io/installation)
- [Git](https://git-scm.com/downloads)
</TabItem>
</Tabs>
## Unix (Linux, macOS)
### 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:
```bash
sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr
@@ -77,8 +40,6 @@ pnpm build
```bash
pnpm start
```
</TabItem>
</Tabs>
:::info
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
@@ -234,33 +195,6 @@ pm2 status jellyseerr
## Windows
### 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:
```powershell
mkdir C:\jellyseerr
@@ -284,8 +218,6 @@ pnpm build
```powershell
pnpm start
```
</TabItem>
</Tabs>
:::tip
You can add the environment variables to a `.env` file in the Jellyseerr directory.
@@ -313,6 +245,7 @@ node dist/index.js
- Set the trigger to "When the computer starts"
- Set the action to "Start a program"
- Set the program/script to the path of the `start-jellyseerr.bat` file
- Set the "Start in" to the jellyseerr directory.
- Click "Finish"
Now, Jellyseerr will start when the computer boots up in the background.

View File

@@ -22,7 +22,7 @@ export const VersionMismatchWarning = () => {
<>
{!isUpToDate ? (
<Admonition type="warning">
The <a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/servers/jellyseerr/default.nix#L14">upstream Jellyseerr Nix Package (v{nixpkgVersion})</a> is not <b>up-to-date</b>. If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>, you will need to <a href="#overriding-the-package">override the package derivation</a>.
The <a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/servers/jellyseerr/default.nix#L14">upstream Jellyseerr Nix Package (v{nixpkgVersion})</a> is not <b>up-to-date</b>. If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>, you will need to <a href="#overriding-the-package-derivation">override the package derivation</a>.
</Admonition>
) : (
<Admonition type="success">
@@ -95,12 +95,12 @@ export const VersionMatch = () => {
};
offlineCache = pkgs.fetchYarnDeps {
sha256 = pkgs.lib.fakeSha256;
sha256 = pkgs.lib.fakeSha256;
};
});
});
};
}`;
const module = `{ config, pkgs, lib, ... }:
with lib;

View File

@@ -12,6 +12,8 @@ This is your Jellyseerr API key, which can be used to integrate Jellyseerr with
If you need to generate a new API key for any reason, simply click the button to the right of the text box.
If you 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
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!

View File

@@ -35,7 +35,7 @@ Users can override the [global display language](/using-jellyseerr/settings/gene
### Discover Region & Discover Language
Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region-and-discover-language) to suit their own preferences.
Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region--discover-language) to suit their own preferences.
### Movie Request Limit & Series Request Limit

View File

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

View File

@@ -38,6 +38,8 @@ tags:
description: Endpoints related to getting service (Radarr/Sonarr) details.
- name: watchlist
description: Collection of media to watch later
- name: blacklist
description: Blacklisted media from discovery page.
servers:
- url: '{server}/api/v1'
variables:
@@ -46,6 +48,19 @@ servers:
components:
schemas:
Blacklist:
type: object
properties:
tmdbId:
type: number
example: 1
title:
type: string
media:
$ref: '#/components/schemas/MediaInfo'
userId:
type: number
example: 1
Watchlist:
type: object
properties:
@@ -2775,6 +2790,15 @@ paths:
imageCount:
type: number
example: 123
avatar:
type: object
properties:
size:
type: number
example: 123456
imageCount:
type: number
example: 123
apiCaches:
type: array
items:
@@ -3586,6 +3610,8 @@ paths:
type: string
email:
type: string
serverType:
type: number
required:
- username
- password
@@ -4040,6 +4066,94 @@ paths:
restricted:
type: boolean
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:
post:
summary: Add media to watchlist
@@ -4862,6 +4976,11 @@ paths:
schema:
type: string
example: 8|9
- in: query
name: status
schema:
type: string
example: 3|4
responses:
'200':
description: Results

View File

@@ -62,6 +62,7 @@
"formik": "^2.4.6",
"gravatar-url": "3.1.0",
"lodash": "4.17.21",
"mime": "3",
"next": "^14.2.4",
"node-cache": "5.1.2",
"node-gyp": "9.3.1",
@@ -92,7 +93,8 @@
"sqlite3": "5.1.4",
"swagger-ui-express": "4.6.2",
"swr": "2.2.5",
"typeorm": "0.3.12",
"typeorm": "0.3.11",
"undici": "^6.20.1",
"web-push": "3.5.0",
"winston": "3.8.2",
"winston-daily-rotate-file": "4.7.1",
@@ -119,6 +121,7 @@
"@types/express": "4.17.17",
"@types/express-session": "1.17.6",
"@types/lodash": "4.14.191",
"@types/mime": "3",
"@types/node": "20.14.8",
"@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7",

90
pnpm-lock.yaml generated
View File

@@ -49,7 +49,7 @@ importers:
version: 2.11.0
connect-typeorm:
specifier: 1.1.4
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)))
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)))
cookie-parser:
specifier: 1.4.6
version: 1.4.6
@@ -98,6 +98,9 @@ importers:
lodash:
specifier: 4.17.21
version: 4.17.21
mime:
specifier: '3'
version: 3.0.0
next:
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)
@@ -189,8 +192,11 @@ importers:
specifier: 2.2.5
version: 2.2.5(react@18.3.1)
typeorm:
specifier: 0.3.12
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))
specifier: 0.3.11
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))
undici:
specifier: ^6.20.1
version: 6.20.1
web-push:
specifier: 3.5.0
version: 3.5.0
@@ -264,6 +270,9 @@ importers:
'@types/lodash':
specifier: 4.14.191
version: 4.14.191
'@types/mime':
specifier: '3'
version: 3.0.4
'@types/node':
specifier: 20.14.8
version: 20.14.8
@@ -2848,6 +2857,9 @@ packages:
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/mime@3.0.4':
resolution: {integrity: sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==}
'@types/minimatch@3.0.5':
resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}
@@ -4255,10 +4267,6 @@ packages:
resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==}
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:
resolution: {integrity: sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==}
@@ -5380,8 +5388,8 @@ packages:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
https-proxy-agent@7.0.4:
resolution: {integrity: sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==}
https-proxy-agent@7.0.5:
resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==}
engines: {node: '>= 14'}
human-signals@1.1.1:
@@ -6545,11 +6553,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
mkdirp@2.1.6:
resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==}
engines: {node: '>=10'}
hasBin: true
modify-values@1.0.1:
resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==}
engines: {node: '>=0.10.0'}
@@ -7721,9 +7724,6 @@ packages:
reflect-metadata@0.1.13:
resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==}
reflect-metadata@0.1.14:
resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==}
reflect.getprototypeof@1.0.6:
resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==}
engines: {node: '>= 0.4'}
@@ -8661,8 +8661,8 @@ packages:
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typeorm@0.3.12:
resolution: {integrity: sha512-sYSxBmCf1nJLLTcYtwqZ+lQIRtLPyUoO93rHTOKk9vJCyT4UfRtU7oRsJvfvKP3nnZTD1hzz2SEy2zwPEN6OyA==}
typeorm@0.3.11:
resolution: {integrity: sha512-pzdOyWbVuz/z8Ww6gqvBW4nylsM0KLdUCDExr2gR20/x1khGSVxQkjNV/3YqliG90jrWzrknYbYscpk8yxFJVg==}
engines: {node: '>= 12.9.0'}
hasBin: true
peerDependencies:
@@ -8673,7 +8673,7 @@ packages:
ioredis: ^5.0.4
mongodb: ^3.6.0
mssql: ^7.3.0
mysql2: ^2.2.5 || ^3.0.1
mysql2: ^2.2.5
oracledb: ^5.1.0
pg: ^8.5.1
pg-native: ^3.0.0
@@ -8759,6 +8759,10 @@ packages:
undici-types@5.26.5:
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:
resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==}
engines: {node: '>=4'}
@@ -10836,7 +10840,7 @@ snapshots:
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.6.2
semver: 7.3.8
tar: 6.2.1
transitivePeerDependencies:
- encoding
@@ -10911,13 +10915,13 @@ snapshots:
'@npmcli/fs@1.1.1':
dependencies:
'@gar/promisify': 1.1.3
semver: 7.6.2
semver: 7.3.8
optional: true
'@npmcli/fs@2.1.2':
dependencies:
'@gar/promisify': 1.1.3
semver: 7.6.2
semver: 7.3.8
'@npmcli/move-file@1.1.2':
dependencies:
@@ -12301,7 +12305,7 @@ snapshots:
fs-extra: 11.2.0
globby: 11.1.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.4
https-proxy-agent: 7.0.5
issue-parser: 6.0.0
lodash: 4.17.21
mime: 3.0.0
@@ -12326,7 +12330,7 @@ snapshots:
read-pkg: 5.2.0
registry-auth-token: 5.0.2
semantic-release: 19.0.5(encoding@0.1.13)
semver: 7.6.2
semver: 7.3.8
tempy: 1.0.1
'@semantic-release/release-notes-generator@10.0.3(semantic-release@19.0.5(encoding@0.1.13))':
@@ -12670,6 +12674,8 @@ snapshots:
'@types/mime@1.3.5': {}
'@types/mime@3.0.4': {}
'@types/minimatch@3.0.5': {}
'@types/minimist@1.2.5': {}
@@ -12887,7 +12893,7 @@ snapshots:
debug: 4.3.5(supports-color@8.1.1)
globby: 11.1.0
is-glob: 4.0.3
semver: 7.6.2
semver: 7.3.8
tsutils: 3.21.0(typescript@4.9.5)
optionalDependencies:
typescript: 4.9.5
@@ -13813,13 +13819,13 @@ snapshots:
ini: 1.3.8
proto-list: 1.2.4
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))):
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))):
dependencies:
'@types/debug': 0.0.31
'@types/express-session': 1.17.6
debug: 4.3.5(supports-color@8.1.1)
express-session: 1.18.0
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))
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))
transitivePeerDependencies:
- supports-color
@@ -14170,10 +14176,6 @@ snapshots:
date-fns@2.29.3: {}
date-fns@2.30.0:
dependencies:
'@babel/runtime': 7.24.7
dateformat@3.0.3: {}
dayjs@1.11.11: {}
@@ -15728,7 +15730,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.4:
https-proxy-agent@7.0.5:
dependencies:
agent-base: 7.1.1
debug: 4.3.5(supports-color@8.1.1)
@@ -17138,8 +17140,6 @@ snapshots:
mkdirp@1.0.4: {}
mkdirp@2.1.6: {}
modify-values@1.0.1: {}
moment@2.30.1: {}
@@ -17269,7 +17269,7 @@ snapshots:
nopt: 5.0.0
npmlog: 6.0.2
rimraf: 3.0.2
semver: 7.6.2
semver: 7.3.8
tar: 6.2.1
which: 2.0.2
transitivePeerDependencies:
@@ -17348,7 +17348,7 @@ snapshots:
dependencies:
hosted-git-info: 4.1.0
is-core-module: 2.14.0
semver: 7.6.2
semver: 7.3.8
validate-npm-package-license: 3.0.4
normalize-path@3.0.0: {}
@@ -18361,8 +18361,6 @@ snapshots:
reflect-metadata@0.1.13: {}
reflect-metadata@0.1.14: {}
reflect.getprototypeof@1.0.6:
dependencies:
call-bind: 1.0.7
@@ -19420,23 +19418,23 @@ snapshots:
typedarray@0.0.6: {}
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)):
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)):
dependencies:
'@sqltools/formatter': 1.2.5
app-root-path: 3.1.0
buffer: 6.0.3
chalk: 4.1.2
cli-highlight: 2.1.11
date-fns: 2.30.0
date-fns: 2.29.3
debug: 4.3.5(supports-color@8.1.1)
dotenv: 16.4.5
glob: 8.1.0
glob: 7.2.3
js-yaml: 4.1.0
mkdirp: 2.1.6
reflect-metadata: 0.1.14
mkdirp: 1.0.4
reflect-metadata: 0.1.13
sha.js: 2.4.11
tslib: 2.6.3
uuid: 9.0.1
uuid: 8.3.2
xml2js: 0.4.23
yargs: 17.7.2
optionalDependencies:
@@ -19475,6 +19473,8 @@ snapshots:
undici-types@5.26.5: {}
undici@6.20.1: {}
unicode-canonical-property-names-ecmascript@2.0.0: {}
unicode-emoji-utils@1.2.0:

View File

@@ -76,7 +76,7 @@ class ExternalAPI {
}
const data = await this.getDataFromResponse(response);
if (this.cache) {
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
}
@@ -120,7 +120,7 @@ class ExternalAPI {
}
const resData = await this.getDataFromResponse(response);
if (this.cache) {
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
}
@@ -164,7 +164,7 @@ class ExternalAPI {
}
const resData = await this.getDataFromResponse(response);
if (this.cache) {
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
}

View File

@@ -182,7 +182,7 @@ class RottenTomatoes extends ExternalAPI {
);
}
if (!tvshow) {
if (!tvshow || !tvshow.rottenTomatoes) {
return null;
}

View File

@@ -157,9 +157,13 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
try {
const data = await this.get<QueueResponse<QueueItemAppendT>>(`/queue`, {
includeEpisode: 'true',
});
const data = await this.get<QueueResponse<QueueItemAppendT>>(
`/queue`,
{
includeEpisode: 'true',
},
0
);
return data.records;
} catch (e) {
@@ -193,15 +197,24 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
}
};
async refreshMonitoredDownloads(): Promise<void> {
await this.runCommand('RefreshMonitoredDownloads', {});
}
protected async runCommand(
commandName: string,
options: Record<string, unknown>
): Promise<void> {
try {
await this.post(`/command`, {
name: commandName,
...options,
});
await this.post(
`/command`,
{
name: commandName,
...options,
},
{},
0
);
} catch (e) {
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
}

View File

@@ -303,10 +303,10 @@ class SonarrAPI extends ServarrBase<{
});
try {
await this.runCommand('SeriesSearch', { seriesId });
await this.runCommand('MissingEpisodeSearch', { seriesId });
} catch (e) {
logger.error(
'Something went wrong while executing Sonarr series search.',
'Something went wrong while executing Sonarr missing episode search.',
{
label: 'Sonarr API',
errorMessage: e.message,

View File

@@ -95,6 +95,7 @@ interface DiscoverTvOptions {
sortBy?: SortOptions;
watchRegion?: string;
watchProviders?: string;
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
}
class TheMovieDb extends ExternalAPI {
@@ -523,6 +524,7 @@ class TheMovieDb extends ExternalAPI {
voteCountLte,
watchProviders,
watchRegion,
withStatus,
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
try {
const defaultFutureDate = new Date(
@@ -570,6 +572,7 @@ class TheMovieDb extends ExternalAPI {
'vote_count.lte': voteCountLte || '',
with_watch_providers: watchProviders || '',
watch_region: watchRegion || '',
with_status: withStatus || '',
});
return data;

View File

@@ -2,6 +2,7 @@ export enum ApiErrorCode {
InvalidUrl = 'INVALID_URL',
InvalidCredentials = 'INVALID_CREDENTIALS',
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
InvalidEmail = 'INVALID_EMAIL',
NotAdmin = 'NOT_ADMIN',
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',

View File

@@ -16,4 +16,5 @@ export enum MediaStatus {
PROCESSING,
PARTIALLY_AVAILABLE,
AVAILABLE,
BLACKLISTED,
}

View File

@@ -4,3 +4,8 @@ export enum MediaServerType {
EMBY,
NOT_CONFIGURED,
}
export enum ServerType {
JELLYFIN = 'Jellyfin',
EMBY = 'Emby',
}

View File

@@ -2,4 +2,5 @@ export enum UserType {
PLEX = 1,
LOCAL = 2,
JELLYFIN = 3,
EMBY = 4,
}

View File

@@ -0,0 +1,95 @@
import { MediaStatus, type MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
@Entity()
@Unique(['tmdbId'])
export class Blacklist implements BlacklistItem {
@PrimaryGeneratedColumn()
public id: number;
@Column({ type: 'varchar' })
public mediaType: MediaType;
@Column({ nullable: true, type: 'varchar' })
title?: string;
@Column()
@Index()
public tmdbId: number;
@ManyToOne(() => User, (user) => user.id, {
eager: true,
})
user: User;
@OneToOne(() => Media, (media) => media.blacklist, {
onDelete: 'CASCADE',
})
@JoinColumn()
public media: Media;
@CreateDateColumn()
public createdAt: Date;
constructor(init?: Partial<Blacklist>) {
Object.assign(this, init);
}
public static async addToBlacklist({
blacklistRequest,
}: {
blacklistRequest: {
mediaType: MediaType;
title?: ZodOptional<ZodString>['_output'];
tmdbId: ZodNumber['_output'];
};
}): Promise<void> {
const blacklist = new this({
...blacklistRequest,
});
const mediaRepository = getRepository(Media);
let media = await mediaRepository.findOne({
where: {
tmdbId: blacklistRequest.tmdbId,
},
});
const blacklistRepository = getRepository(this);
await blacklistRepository.save(blacklist);
if (!media) {
media = new Media({
tmdbId: blacklistRequest.tmdbId,
status: MediaStatus.BLACKLISTED,
status4k: MediaStatus.BLACKLISTED,
mediaType: blacklistRequest.mediaType,
blacklist: blacklist,
});
await mediaRepository.save(media);
} else {
media.blacklist = blacklist;
media.status = MediaStatus.BLACKLISTED;
media.status4k = MediaStatus.BLACKLISTED;
await mediaRepository.save(media);
}
}
}

View File

@@ -3,6 +3,7 @@ import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import type { User } from '@server/entity/User';
import { Watchlist } from '@server/entity/Watchlist';
import type { DownloadingItem } from '@server/lib/downloadtracker';
@@ -17,6 +18,7 @@ import {
Entity,
Index,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@@ -66,7 +68,7 @@ class Media {
try {
const media = await mediaRepository.findOne({
where: { tmdbId: id, mediaType },
where: { tmdbId: id, mediaType: mediaType },
relations: { requests: true, issues: true },
});
@@ -116,6 +118,11 @@ class Media {
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
public issues: Issue[];
@OneToOne(() => Blacklist, (blacklist) => blacklist.media, {
eager: true,
})
public blacklist: Blacklist;
@CreateDateColumn()
public createdAt: Date;
@@ -211,9 +218,10 @@ class Media {
}
} else {
const pageName =
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
getSettings().main.mediaServerType == MediaServerType.EMBY
? 'item'
: 'details';
const { serverId, externalHostname } = getSettings().jellyfin;
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
@@ -223,7 +231,7 @@ class Media {
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
}
if (this.jellyfinMediaId4k) {
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`;
}
}
}

View File

@@ -40,6 +40,7 @@ export class RequestPermissionError extends Error {}
export class QuotaRestrictedError extends Error {}
export class DuplicateMediaRequestError extends Error {}
export class NoSeasonsAvailableError extends Error {}
export class BlacklistedMediaError extends Error {}
type MediaRequestOptions = {
isAutoRequest?: boolean;
@@ -143,6 +144,16 @@ export class MediaRequest {
mediaType: requestBody.mediaType,
});
} 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) {
media.status = MediaStatus.PENDING;
}

View File

@@ -19,6 +19,7 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes';
import avatarproxy from '@server/routes/avatarproxy';
import imageproxy from '@server/routes/imageproxy';
import { getAppVersion } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag';
@@ -36,6 +37,7 @@ import dns from 'node:dns';
import net from 'node:net';
import path from 'path';
import swaggerUi from 'swagger-ui-express';
import { ProxyAgent, setGlobalDispatcher } from 'undici';
import YAML from 'yamljs';
if (process.env.forceIpv4First === 'true') {
@@ -66,6 +68,11 @@ app
const settings = await getSettings().load();
restartFlag.initializeSettings(settings.main);
// Register HTTP proxy
if (settings.main.httpProxy) {
setGlobalDispatcher(new ProxyAgent(settings.main.httpProxy));
}
// Migrate library types
if (
settings.plex.libraries.length > 1 &&
@@ -174,7 +181,7 @@ app
},
store: new TypeormStore({
cleanupLimit: 2,
ttl: 1000 * 60 * 60 * 24 * 30,
ttl: 60 * 60 * 24 * 30,
}).connect(sessionRespository) as Store,
})
);
@@ -202,6 +209,7 @@ app
// Do not set cookies so CDNs can cache them
server.use('/imageproxy', clearCookies, imageproxy);
server.use('/avatarproxy', clearCookies, avatarproxy);
server.get('*', (req, res) => handle(req, res));
server.use(

View File

@@ -0,0 +1,14 @@
import type { User } from '@server/entity/User';
import type { PaginatedResponse } from '@server/interfaces/api/common';
export interface BlacklistItem {
tmdbId: number;
mediaType: 'movie' | 'tv';
title?: string;
createdAt?: Date;
user: User;
}
export interface BlacklistResultsResponse extends PaginatedResponse {
results: BlacklistItem[];
}

View File

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

View File

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

View File

@@ -85,6 +85,7 @@ class DownloadTracker {
});
try {
await radarr.refreshMonitoredDownloads();
const queueItems = await radarr.getQueue();
this.radarrServers[server.id] = queueItems.map((item) => ({
@@ -162,6 +163,7 @@ class DownloadTracker {
});
try {
await sonarr.refreshMonitoredDownloads();
const queueItems = await sonarr.getQueue();
this.sonarrServers[server.id] = queueItems.map((item) => ({

View File

@@ -3,6 +3,7 @@ import type { RateLimitOptions } from '@server/utils/rateLimit';
import rateLimit from '@server/utils/rateLimit';
import { createHash } from 'crypto';
import { promises } from 'fs';
import mime from 'mime/lite';
import path, { join } from 'path';
type ImageResponse = {
@@ -11,7 +12,7 @@ type ImageResponse = {
curRevalidate: number;
isStale: boolean;
etag: string;
extension: string;
extension: string | null;
cacheKey: string;
cacheMiss: boolean;
};
@@ -27,29 +28,45 @@ class ImageProxy {
let deletedImages = 0;
const cacheDirectory = path.join(baseCacheDirectory, key);
const files = await promises.readdir(cacheDirectory);
try {
const files = await promises.readdir(cacheDirectory);
for (const file of files) {
const filePath = path.join(cacheDirectory, file);
const stat = await promises.lstat(filePath);
for (const file of files) {
const filePath = path.join(cacheDirectory, file);
const stat = await promises.lstat(filePath);
if (stat.isDirectory()) {
const imageFiles = await promises.readdir(filePath);
if (stat.isDirectory()) {
const imageFiles = await promises.readdir(filePath);
for (const imageFile of imageFiles) {
const [, expireAtSt] = imageFile.split('.');
const expireAt = Number(expireAtSt);
const now = Date.now();
for (const imageFile of imageFiles) {
const [, expireAtSt] = imageFile.split('.');
const expireAt = Number(expireAtSt);
const now = Date.now();
if (now > expireAt) {
await promises.rm(path.join(filePath, imageFile));
deletedImages += 1;
if (now > expireAt) {
await promises.rm(path.join(filePath), {
recursive: true,
});
deletedImages += 1;
}
}
}
}
} catch (e) {
if (e.code === 'ENOENT') {
logger.error('Directory not found', {
label: 'Image Cache',
message: e.message,
});
} else {
logger.error('Failed to read directory', {
label: 'Image Cache',
message: e.message,
});
}
}
logger.info(`Cleared ${deletedImages} stale image(s) from cache`, {
logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, {
label: 'Image Cache',
});
}
@@ -69,33 +86,49 @@ class ImageProxy {
}
private static async getDirectorySize(dir: string): Promise<number> {
const files = await promises.readdir(dir, {
withFileTypes: true,
});
try {
const files = await promises.readdir(dir, {
withFileTypes: true,
});
const paths = files.map(async (file) => {
const path = join(dir, file.name);
const paths = files.map(async (file) => {
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()) {
const { size } = await promises.stat(path);
if (file.isFile()) {
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 (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
return 0;
}
private static async getImageCount(dir: string) {
const files = await promises.readdir(dir);
try {
const files = await promises.readdir(dir);
return files.length;
return files.length;
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
}
}
return 0;
}
private fetch: typeof fetch;
@@ -147,6 +180,27 @@ class ImageProxy {
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> {
try {
const directory = join(this.getCacheDirectory(), cacheKey);
@@ -187,16 +241,25 @@ class ImageProxy {
const directory = join(this.getCacheDirectory(), cacheKey);
const href =
this.baseUrl +
(this.baseUrl.endsWith('/') ? '' : '/') +
(this.baseUrl.length > 0
? this.baseUrl.endsWith('/')
? ''
: '/'
: '') +
(path.startsWith('/') ? path.slice(1) : path);
const response = await this.fetch(href);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extension = path.split('.').pop() ?? '';
const maxAge = Number(
const extension = mime.getExtension(
response.headers.get('content-type') ?? ''
);
let maxAge = Number(
(response.headers.get('cache-control') ?? '0').split('=')[1]
);
if (!maxAge) maxAge = 86400;
const expireAt = Date.now() + maxAge * 1000;
const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
@@ -232,7 +295,7 @@ class ImageProxy {
private async writeToCacheDir(
dir: string,
extension: string,
extension: string | null,
maxAge: number,
expireAt: number,
buffer: Buffer,

View File

@@ -27,6 +27,8 @@ export enum Permission {
AUTO_REQUEST_TV = 33554432,
RECENT_VIEW = 67108864,
WATCHLIST_VIEW = 134217728,
MANAGE_BLACKLIST = 268435456,
VIEW_BLACKLIST = 1073741824,
}
export interface PermissionCheckOptions {

View File

@@ -567,7 +567,10 @@ class JellyfinScanner {
public async run(): Promise<void> {
const settings = getSettings();
if (settings.main.mediaServerType != MediaServerType.JELLYFIN) {
if (
settings.main.mediaServerType != MediaServerType.JELLYFIN &&
settings.main.mediaServerType != MediaServerType.EMBY
) {
return;
}

View File

@@ -119,6 +119,7 @@ export interface MainSettings {
mediaServerType: number;
partialRequestsEnabled: boolean;
locale: string;
httpProxy: string;
}
interface PublicSettings {
@@ -325,6 +326,7 @@ class Settings {
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
locale: 'en',
httpProxy: '',
},
plex: {
name: '',
@@ -611,7 +613,11 @@ class Settings {
}
private generateApiKey(): string {
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
if (process.env.API_KEY) {
return process.env.API_KEY;
} else {
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
}
}
private generateVapidKeys(force = false): void {
@@ -644,10 +650,16 @@ class Settings {
if (data) {
const parsedJson = JSON.parse(data);
this.data = await runMigrations(parsedJson);
this.data = await runMigrations(parsedJson, SETTINGS_PATH);
this.data = merge(this.data, parsedJson);
if (process.env.API_KEY) {
if (this.main.apiKey != process.env.API_KEY) {
this.main.apiKey = process.env.API_KEY;
}
}
this.save();
}
return this;

View File

@@ -0,0 +1,16 @@
import { MediaServerType } from '@server/constants/server';
import type { AllSettings } from '@server/lib/settings';
const migrateHostname = (settings: any): AllSettings => {
const oldMediaServerType = settings.main.mediaServerType;
if (
oldMediaServerType === MediaServerType.JELLYFIN &&
process.env.JELLYFIN_TYPE === 'emby'
) {
settings.main.mediaServerType = MediaServerType.EMBY;
}
return settings;
};
export default migrateHostname;

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-console */
import type { AllSettings } from '@server/lib/settings';
import logger from '@server/logger';
import fs from 'fs';
@@ -6,7 +7,8 @@ import path from 'path';
const migrationsDir = path.join(__dirname, 'migrations');
export const runMigrations = async (
settings: AllSettings
settings: AllSettings,
SETTINGS_PATH: string
): Promise<AllSettings> => {
const migrations = fs
.readdirSync(migrationsDir)
@@ -17,14 +19,43 @@ export const runMigrations = async (
let migrated = settings;
try {
const settingsBefore = JSON.stringify(migrated);
for (const migration of migrations) {
migrated = await migration(migrated);
}
const settingsAfter = JSON.stringify(migrated);
if (settingsBefore !== settingsAfter) {
// a migration occured
// we check that the new config will be saved
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(migrated, undefined, ' '));
const fileSaved = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8'));
if (JSON.stringify(fileSaved) !== settingsAfter) {
// something went wrong while saving file
throw new Error('Unable to save settings after migration.');
}
}
} catch (e) {
logger.error(
`Something went wrong while running settings migrations: ${e.message}`,
{ 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;

View File

@@ -0,0 +1,20 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBlacklist1699901142442 implements MigrationInterface {
name = 'AddBlacklist1699901142442';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')),"userId" integer, "mediaId" integer,CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId", "userId"))`
);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "blacklist"`);
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
}
}

View File

@@ -1,11 +1,12 @@
import JellyfinAPI from '@server/api/jellyfin';
import PlexTvAPI from '@server/api/plextv';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType } from '@server/constants/server';
import { MediaServerType, ServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { startJobs } from '@server/job/schedule';
import ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
@@ -227,15 +228,20 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
urlBase?: string;
useSsl?: boolean;
email?: string;
serverType?: number;
};
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured
//Make sure jellyfin login is enabled, but only if jellyfin && Emby is not already configured
if (
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
settings.main.mediaServerType !== MediaServerType.EMBY &&
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
settings.jellyfin.ip !== ''
) {
return res.status(500).json({ error: 'Jellyfin login is disabled' });
} else if (!body.username) {
}
if (!body.username) {
return res.status(500).json({ error: 'You must provide an username' });
} else if (settings.jellyfin.ip !== '' && body.hostname) {
return res
@@ -256,8 +262,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
urlBase: body.urlBase,
});
const { externalHostname } = getSettings().jellyfin;
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
let user = await userRepository.findOne({
where: { jellyfinUsername: body.username },
@@ -273,11 +277,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
}
// First we need to attempt to log the user in to jellyfin
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
const ip = req.ip;
let clientIp;
@@ -317,22 +317,49 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
);
// User doesn't exist, and there are no users in the database, we'll create the user
// with admin permission
settings.main.mediaServerType = MediaServerType.JELLYFIN;
user = new User({
email: body.email || account.User.Name,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
permissions: Permission.ADMIN,
avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email || account.User.Name, {
default: 'mm',
size: 200,
}),
userType: UserType.JELLYFIN,
});
// with admin permissions
switch (body.serverType) {
case MediaServerType.EMBY:
settings.main.mediaServerType = MediaServerType.EMBY;
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: account.User.PrimaryImageTag
? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email || account.User.Name, {
default: 'mm',
size: 200,
}),
userType: UserType.EMBY,
});
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: account.User.PrimaryImageTag
? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email || account.User.Name, {
default: 'mm',
size: 200,
}),
userType: UserType.JELLYFIN,
});
break;
default:
throw new Error('select_server_type');
}
// Create an API key on Jellyfin from this admin user
const jellyfinClient = new JellyfinAPI(
@@ -361,12 +388,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
logger.info(
`Found matching ${
settings.main.mediaServerType === MediaServerType.JELLYFIN
? 'Jellyfin'
: 'Emby'
? ServerType.JELLYFIN
: ServerType.EMBY
} user; updating user with ${
settings.main.mediaServerType === MediaServerType.JELLYFIN
? 'Jellyfin'
: 'Emby'
? ServerType.JELLYFIN
: ServerType.EMBY
}`,
{
label: 'API',
@@ -376,12 +403,24 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
);
// 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`;
const avatar = `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
if (avatar !== user.avatar) {
const avatarProxy = new ImageProxy('avatar', '');
avatarProxy.clearCachedImage(user.avatar);
}
user.avatar = avatar;
} else {
user.avatar = gravatarUrl(user.email || account.User.Name, {
const avatar = gravatarUrl(user.email || account.User.Name, {
default: 'mm',
size: 200,
});
if (avatar !== user.avatar) {
const avatarProxy = new ImageProxy('avatar', '');
avatarProxy.clearCachedImage(user.avatar);
}
user.avatar = avatar;
}
user.jellyfinUsername = account.User.Name;
@@ -389,12 +428,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
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);
} else if (!settings.main.newPlexLogin) {
logger.warn(
@@ -427,13 +460,17 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinDeviceId: deviceId,
permissions: settings.main.defaultPermissions,
avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email || account.User.Name, {
default: 'mm',
size: 200,
}),
userType: UserType.JELLYFIN,
userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
});
//initialize Jellyfin/Emby users with local login
const passedExplicitPassword = body.password && body.password.length > 0;
if (passedExplicitPassword) {

View File

@@ -0,0 +1,51 @@
import { MediaServerType } from '@server/constants/server';
import ImageProxy from '@server/lib/imageproxy';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
const router = Router();
const avatarImageProxy = new ImageProxy('avatar', '');
// Proxy avatar images
router.get('/*', async (req, res) => {
let imagePath = '';
try {
const jellyfinAvatar = req.url.match(
/(\/Users\/\w+\/Images\/Primary\/?\?tag=\w+&quality=90)$/
)?.[1];
if (!jellyfinAvatar) {
const mediaServerType = getSettings().main.mediaServerType;
throw new Error(
`Provided URL is not ${
mediaServerType === MediaServerType.JELLYFIN
? 'a Jellyfin'
: 'an Emby'
} avatar.`
);
}
const imageUrl = new URL(jellyfinAvatar, getHostname());
imagePath = imageUrl.toString();
const imageData = await avatarImageProxy.getImage(imagePath);
res.writeHead(200, {
'Content-Type': `image/${imageData.meta.extension}`,
'Content-Length': imageData.imageBuffer.length,
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
'OS-Cache-Key': imageData.meta.cacheKey,
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
});
res.end(imageData.imageBuffer);
} catch (e) {
logger.error('Failed to proxy avatar image', {
imagePath,
errorMessage: e.message,
});
}
});
export default router;

148
server/routes/blacklist.ts Normal file
View File

@@ -0,0 +1,148 @@
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;

View File

@@ -71,6 +71,7 @@ const QueryFilterOptions = z.object({
network: z.coerce.string().optional(),
watchProviders: z.coerce.string().optional(),
watchRegion: z.coerce.string().optional(),
status: z.coerce.string().optional(),
});
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
@@ -385,6 +386,7 @@ discoverRoutes.get('/tv', async (req, res, next) => {
voteCountLte: query.voteCountLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
withStatus: query.status,
});
const media = await Media.getRelatedMedia(

View File

@@ -23,6 +23,7 @@ import restartFlag from '@server/utils/restartFlag';
import { isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import authRoutes from './auth';
import blacklistRoutes from './blacklist';
import collectionRoutes from './collection';
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
import issueRoutes from './issue';
@@ -144,6 +145,7 @@ router.use('/search', isAuthenticated(), searchRoutes);
router.use('/discover', isAuthenticated(), discoverRoutes);
router.use('/request', isAuthenticated(), requestRoutes);
router.use('/watchlist', isAuthenticated(), watchlistRoutes);
router.use('/blacklist', isAuthenticated(), blacklistRoutes);
router.use('/movie', isAuthenticated(), movieRoutes);
router.use('/tv', isAuthenticated(), tvRoutes);
router.use('/media', isAuthenticated(), mediaRoutes);

View File

@@ -8,6 +8,7 @@ import {
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import {
BlacklistedMediaError,
DuplicateMediaRequestError,
MediaRequest,
NoSeasonsAvailableError,
@@ -243,6 +244,8 @@ requestRoutes.post<never, MediaRequest, MediaRequestBody>(
return next({ status: 409, message: error.message });
case NoSeasonsAvailableError:
return next({ status: 202, message: error.message });
case BlacklistedMediaError:
return next({ status: 403, message: error.message });
default:
return next({ status: 500, message: error.message });
}

View File

@@ -377,11 +377,6 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
settingsRoutes.get('/jellyfin/users', async (req, res) => {
const settings = getSettings();
const { externalHostname } = settings.jellyfin;
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: getHostname();
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
@@ -401,7 +396,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
username: user.Name,
id: user.Id,
thumb: user.PrimaryImageTag
? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
? `/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
: gravatarUrl(user.Name, { default: 'mm', size: 200 }),
email: user.Name,
}));
@@ -746,11 +741,13 @@ settingsRoutes.get('/cache', async (_req, res) => {
}));
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
const avatarImageCache = await ImageProxy.getImageStats('avatar');
return res.status(200).json({
apiCaches,
imageCache: {
tmdb: tmdbImageCache,
avatar: avatarImageCache,
},
});
});

View File

@@ -2,6 +2,7 @@ import JellyfinAPI from '@server/api/jellyfin';
import PlexTvAPI from '@server/api/plextv';
import TautulliAPI from '@server/api/tautulli';
import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
@@ -515,12 +516,6 @@ router.post(
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = [];
const { externalHostname } = getSettings().jellyfin;
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const jellyfinUsers = await jellyfinClient.getUsers();
@@ -545,12 +540,15 @@ router.post(
email: jellyfinUser?.Name,
permissions: settings.main.defaultPermissions,
avatar: jellyfinUser?.PrimaryImageTag
? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
? `/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
: gravatarUrl(jellyfinUser?.Name ?? '', {
default: 'mm',
size: 200,
}),
userType: UserType.JELLYFIN,
userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
});
await userRepository.save(newUser);

View File

@@ -1,3 +1,4 @@
import { ApiErrorCode } from '@server/constants/error';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { UserSettings } from '@server/entity/UserSettings';
@@ -9,6 +10,7 @@ import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { ApiError } from '@server/types/error';
import { Router } from 'express';
import { canMakePermissionsChange } from '.';
@@ -98,10 +100,18 @@ userSettingsRoutes.post<
}
user.username = req.body.username;
const oldEmail = user.email;
if (user.jellyfinUsername) {
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
if (
!user.hasPermission(Permission.MANAGE_USERS) &&
@@ -145,7 +155,14 @@ userSettingsRoutes.post<
email: savedUser.email,
});
} catch (e) {
next({ status: 500, message: e.message });
if (e.errorCode) {
return next({
status: e.statusCode,
message: e.errorCode,
});
} else {
return next({ status: 500, message: e.message });
}
}
});

View File

@@ -13,7 +13,8 @@ class RestartFlag {
return (
this.settings.csrfProtection !== settings.csrfProtection ||
this.settings.trustProxy !== settings.trustProxy
this.settings.trustProxy !== settings.trustProxy ||
this.settings.httpProxy !== settings.httpProxy
);
}
}

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
id="svg2"
viewBox="0 0 712.60077 712.5481"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs4" />
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<rect
style="opacity:0;fill:#ffffff;stroke-width:4.12543"
id="rect249"
width="712.60077"
height="712.5481"
x="-0.00071160076"
y="2.0223413e-11" />
<rect
style="fill:#ffffff"
id="rect289"
width="230.18982"
height="229.82355"
x="241.20476"
y="241.36227" />
<g
id="layer1"
transform="matrix(0.70249853,0,0,0.70249853,88.770447,96.84571)">
<path
id="path3427"
d="m 327.06546,642.18589 c -45.39663,-45.86009 -82.73776,-83.3683 -82.98029,-83.3516 -0.24253,0.0167 -7.23324,6.65975 -15.53493,14.7623 l -15.09396,14.73193 -40.13624,-40.38805 C 151.24511,525.72706 108.73555,482.86504 78.854363,452.69158 l -54.329437,-54.86086 83.720394,-82.90796 83.72039,-82.90797 -15.19316,-15.20441 -15.19315,-15.20443 95.18008,-94.29313 c 52.34904,-51.86121 95.35849,-94.293118 95.57653,-94.293118 0.21805,0 37.39519,37.357576 82.61589,83.016832 45.22068,45.659256 82.53772,83.131956 82.92673,83.272666 0.38901,0.14071 7.46336,-6.49498 15.72077,-14.746 l 15.01348,-15.00184 7.14591,7.19902 c 73.95232,74.50189 181.50599,183.56427 181.36678,183.9109 -0.10065,0.25064 -37.54056,37.44106 -83.19981,82.64536 -45.65926,45.2043 -83.10802,82.41429 -83.21946,82.68884 -0.11145,0.27456 6.50478,7.34753 14.70272,15.71771 l 14.90534,15.21851 -15.3888,15.28883 c -21.09609,20.95904 -162.95155,161.27018 -169.79551,167.947 l -5.52526,5.39033 z m 89.8523,-204.1566 c 64.84836,-37.53366 117.81919,-68.54793 117.71294,-68.92058 -0.15927,-0.55862 -233.55022,-136.2489 -236.27084,-137.3646 -0.68441,-0.28068 -0.85761,27.45642 -0.85761,137.33982 0,99.83563 0.20749,137.62237 0.75471,137.43996 0.41509,-0.13837 53.81245,-30.96093 118.6608,-68.4946 z"
style="fill:#52b54b;fill-opacity:1;stroke:none" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,46 +1,131 @@
<?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>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
y="0px" width="100%" viewBox="0 0 617 188" enable-background="new 0 0 617 188" xml:space="preserve">
<path fill="#52B54B" opacity="1.000000" stroke="none" d="
M89.583336,1.000000
C90.189529,1.685005 90.166168,2.574803 90.599510,3.025271
C103.718315,16.662701 116.882103,30.256845 129.948212,43.764053
C130.577850,43.523941 130.916519,43.491173 131.111343,43.306595
C138.657471,36.157455 138.655273,36.156090 146.005478,43.505203
C159.538589,57.036308 173.016449,70.623535 186.654617,84.047913
C189.264145,86.616562 189.414017,88.253456 186.716782,90.895164
C174.709808,102.655037 162.893280,114.609337 151.008514,126.493958
C146.073502,131.428925 146.076691,131.427155 151.017944,136.523712
C151.698944,137.226120 152.340485,137.966812 153.259171,138.973434
C151.947098,140.380035 150.766312,141.712204 149.516266,142.975861
C134.544815,158.110641 119.563087,173.235260 104.792023,188.681274
C103.611107,189.000000 102.222221,189.000000 100.624634,188.681274
C86.361732,174.796494 72.307518,161.230438 57.702755,147.132965
C56.157101,149.136856 54.135899,151.757263 51.994804,154.533112
C35.932781,138.457108 20.569420,123.048477 5.141897,107.704361
C3.997114,106.565773 2.391420,105.890610 1.000000,105.000000
C1.000000,103.611107 1.000000,102.222221 1.318741,100.624641
C15.203506,86.361694 28.769531,72.307434 42.867004,57.702602
C40.863205,56.156994 38.242813,54.135792 35.425343,51.962570
C51.518696,35.908516 66.939468,20.557360 82.295547,5.141749
C83.434830,3.998048 84.109390,2.391417 85.000000,0.999999
C86.388893,1.000000 87.777779,1.000000 89.583336,1.000000
M73.196465,79.500702
C73.196465,96.254150 73.196465,113.007599 73.196465,130.872055
C94.273178,118.764557 114.417175,107.192863 135.221664,95.241745
C114.247169,83.251732 94.091187,71.729622 73.196594,59.785294
C73.196594,66.631348 73.196594,72.566254 73.196465,79.500702
z" />
<path fill="#FDFDFD" opacity="1.000000" stroke="none" d="
M618.000000,60.571537
C617.004395,62.042580 615.613281,62.912964 615.073181,64.153824
C608.143372,80.073746 601.328613,96.043816 594.498169,112.006920
C586.973572,129.592300 579.343018,147.133865 571.999390,164.794601
C568.632385,172.892075 568.893372,173.002594 560.133972,172.999832
C555.470825,172.998367 550.807617,172.994385 546.144592,172.969360
C545.841980,172.967712 545.540466,172.775543 544.836609,172.534256
C548.592896,163.531219 551.714905,154.222061 556.286133,145.689255
C559.733765,139.253830 559.138794,134.062668 556.454224,127.695969
C546.360352,103.757523 536.803345,79.592712 526.837830,55.000847
C534.817078,55.000847 542.437622,54.725182 550.003540,55.244331
C551.436218,55.342628 553.169678,58.412052 553.885010,60.423309
C558.720520,74.018005 563.307556,87.700912 568.003784,101.345413
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: 2.4 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -0,0 +1,420 @@
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>
);
};

View File

@@ -0,0 +1,129 @@
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;

View File

@@ -0,0 +1,79 @@
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;

View File

@@ -183,6 +183,11 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
);
}
const blacklistVisibility = hasPermission(
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
{ type: 'or' }
);
return (
<div
className="media-page"
@@ -193,6 +198,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
{data.backdropPath && (
<div className="media-page-bg-image">
<CachedImage
type="tmdb"
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -223,6 +229,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
<div className="media-header">
<div className="media-poster">
<CachedImage
type="tmdb"
src={
data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
@@ -335,20 +342,26 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
sliderKey="collection-movies"
isLoading={false}
isEmpty={data.parts.length === 0}
items={data.parts.map((title) => (
<TitleCard
key={`collection-movie-${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}
/>
))}
items={data.parts
.filter((title) => {
if (!blacklistVisibility)
return title.mediaInfo?.status !== MediaStatus.BLACKLISTED;
return title;
})
.map((title) => (
<TitleCard
key={`collection-movie-${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}
/>
))}
/>
<div className="extra-bottom-space relative" />
</div>

View File

@@ -4,21 +4,34 @@ import Image from 'next/image';
const imageLoader: ImageLoader = ({ src }) => src;
export type CachedImageProps = ImageProps & {
src: string;
type: 'tmdb' | 'avatar';
};
/**
* The CachedImage component should be used wherever
* we want to offer the option to locally cache images.
**/
const CachedImage = ({ src, ...props }: ImageProps) => {
const CachedImage = ({ src, type, ...props }: CachedImageProps) => {
const { currentSettings } = useSettings();
let imageUrl = src;
let imageUrl: string;
if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
const parsedUrl = new URL(imageUrl);
if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) {
imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy');
}
if (type === 'tmdb') {
// tmdb stuff
imageUrl =
currentSettings.cacheImages && !src.startsWith('/')
? src.replace(/^https:\/\/image\.tmdb\.org\//, '/imageproxy/')
: src;
} else if (type === 'avatar') {
// jellyfin avatar (in any)
const jellyfinAvatar = src.match(
/(\/Users\/\w+\/Images\/Primary\/?\?tag=\w+&quality=90)$/
)?.[1];
imageUrl = jellyfinAvatar ? `/avatarproxy` + jellyfinAvatar : src;
} else {
return null;
}
return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />;

View File

@@ -61,6 +61,7 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
{...props}
>
<CachedImage
type="tmdb"
className="absolute inset-0 h-full w-full"
alt=""
src={imageUrl}

View File

@@ -1,8 +1,10 @@
import PersonCard from '@app/components/PersonCard';
import TitleCard from '@app/components/TitleCard';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, useUser } from '@app/hooks/useUser';
import useVerticalScroll from '@app/hooks/useVerticalScroll';
import globalMessages from '@app/i18n/globalMessages';
import { MediaStatus } from '@server/constants/media';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import type {
CollectionResult,
@@ -32,7 +34,14 @@ const ListView = ({
mutateParent,
}: ListViewProps) => {
const intl = useIntl();
const { hasPermission } = useUser();
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
const blacklistVisibility = hasPermission(
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
{ type: 'or' }
);
return (
<>
{isEmpty && (
@@ -55,76 +64,89 @@ const ListView = ({
</li>
);
})}
{items?.map((title, index) => {
let titleCard: React.ReactNode;
{items
?.filter((title) => {
if (!blacklistVisibility)
return (
(title as TvResult | MovieResult).mediaInfo?.status !==
MediaStatus.BLACKLISTED
);
return title;
})
.map((title, index) => {
let titleCard: React.ReactNode;
switch (title.mediaType) {
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;
}
switch (title.mediaType) {
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>;
})}
return <li key={`${title.id}-${index}`}>{titleCard}</li>;
})}
{isLoading &&
!isReachingEnd &&
[...Array(20)].map((_item, i) => (

View File

@@ -123,6 +123,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
{backdrop && (
<div className="absolute top-0 left-0 right-0 z-0 h-64 max-h-full w-full">
<CachedImage
type="tmdb"
alt=""
src={backdrop}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}

View File

@@ -1,6 +1,11 @@
import Spinner from '@app/assets/spinner.svg';
import { CheckCircleIcon } from '@heroicons/react/20/solid';
import { BellIcon, ClockIcon, MinusSmallIcon } from '@heroicons/react/24/solid';
import {
BellIcon,
ClockIcon,
EyeSlashIcon,
MinusSmallIcon,
} from '@heroicons/react/24/solid';
import { MediaStatus } from '@server/constants/media';
interface StatusBadgeMiniProps {
@@ -44,6 +49,10 @@ const StatusBadgeMini = ({
);
indicatorIcon = <BellIcon />;
break;
case MediaStatus.BLACKLISTED:
badgeStyle.push('bg-red-500 border-white-400 ring-white-400 text-white');
indicatorIcon = <EyeSlashIcon />;
break;
case MediaStatus.PARTIALLY_AVAILABLE:
badgeStyle.push(
'bg-green-500 border-green-400 ring-green-400 text-green-100'

View File

@@ -33,6 +33,7 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => {
>
<div className="relative h-full w-full">
<CachedImage
type="tmdb"
src={image}
alt={name}
className="relative z-40 h-full w-full"

View File

@@ -8,6 +8,7 @@ import {
CompanySelector,
GenreSelector,
KeywordSelector,
StatusSelector,
WatchProviderSelector,
} from '@app/components/Selector';
import useSettings from '@app/hooks/useSettings';
@@ -40,6 +41,7 @@ const messages = defineMessages('components.Discover.FilterSlideover', {
runtime: 'Runtime',
streamingservices: 'Streaming Services',
voteCount: 'Number of votes between {minValue} and {maxValue}',
status: 'Status',
});
type FilterSlideoverProps = {
@@ -150,6 +152,23 @@ const FilterSlideover = ({
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">
{intl.formatMessage(messages.keywords)}
</span>

View File

@@ -108,6 +108,7 @@ export const QueryFilterOptions = z.object({
voteCountGte: z.string().optional(),
watchRegion: z.string().optional(),
watchProviders: z.string().optional(),
status: z.string().optional(),
});
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
@@ -147,6 +148,10 @@ export const prepareFilterValues = (
filterValues.genre = values.genre;
}
if (values.status) {
filterValues.status = values.status;
}
if (values.keywords) {
filterValues.keywords = values.keywords;
}

View File

@@ -11,7 +11,6 @@ import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings';
import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import getConfig from 'next/config';
interface ExternalLinkBlockProps {
mediaType: 'movie' | 'tv';
@@ -31,7 +30,6 @@ const ExternalLinkBlock = ({
mediaUrl,
}: ExternalLinkBlockProps) => {
const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
const { locale } = useLocale();
return (
@@ -45,7 +43,8 @@ const ExternalLinkBlock = ({
>
{settings.currentSettings.mediaServerType === MediaServerType.PLEX ? (
<PlexLogo />
) : publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? (
) : settings.currentSettings.mediaServerType ===
MediaServerType.EMBY ? (
<EmbyLogo />
) : (
<JellyfinLogo />

View File

@@ -36,6 +36,7 @@ const GenreCard = ({ image, url, name, canExpand = false }: GenreCardProps) => {
tabIndex={0}
>
<CachedImage
type="tmdb"
src={image}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}

View File

@@ -1,4 +1,5 @@
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import Modal from '@app/components/Common/Modal';
import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
@@ -6,7 +7,6 @@ import { Menu, Transition } from '@headlessui/react';
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import type { default as IssueCommentType } from '@server/entity/IssueComment';
import { Field, Form, Formik } from 'formik';
import Image from 'next/image';
import Link from 'next/link';
import { Fragment, useState } from 'react';
import { FormattedRelativeTime, useIntl } from 'react-intl';
@@ -88,7 +88,8 @@ const IssueComment = ({
</Modal>
</Transition>
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
<Image
<CachedImage
type="avatar"
src={comment.user.avatar}
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"

View File

@@ -28,8 +28,6 @@ import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
@@ -108,7 +106,6 @@ const IssueDetails = () => {
(opt) => opt.issueType === issueData?.issueType
);
const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
if (!data && !error) {
return <LoadingSpinner />;
@@ -220,6 +217,7 @@ const IssueDetails = () => {
{data.backdropPath && (
<div className="media-page-bg-image">
<CachedImage
type="tmdb"
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -238,6 +236,7 @@ const IssueDetails = () => {
<div className="media-header">
<div className="media-poster">
<CachedImage
type="tmdb"
src={
data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
@@ -289,10 +288,11 @@ const IssueDetails = () => {
}
className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
>
<Image
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"
<CachedImage
type="avatar"
src={issueData.createdBy.avatar}
alt=""
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
width={20}
height={20}
/>
@@ -390,7 +390,8 @@ const IssueDetails = () => {
>
<PlayIcon />
<span>
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
{settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? intl.formatMessage(messages.playonplex, {
mediaServerName: 'Emby',
})
@@ -437,7 +438,8 @@ const IssueDetails = () => {
>
<PlayIcon />
<span>
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
{settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? intl.formatMessage(messages.play4konplex, {
mediaServerName: 'Emby',
})
@@ -662,7 +664,8 @@ const IssueDetails = () => {
>
<PlayIcon />
<span>
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
{settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? intl.formatMessage(messages.playonplex, {
mediaServerName: 'Emby',
})
@@ -708,7 +711,8 @@ const IssueDetails = () => {
>
<PlayIcon />
<span>
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
{settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? intl.formatMessage(messages.play4konplex, {
mediaServerName: 'Emby',
})

View File

@@ -11,7 +11,6 @@ import { MediaType } from '@server/constants/media';
import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import Image from 'next/image';
import Link from 'next/link';
import { useInView } from 'react-intersection-observer';
import { FormattedRelativeTime, useIntl } from 'react-intl';
@@ -113,6 +112,7 @@ const IssueItem = ({ issue }: IssueItemProps) => {
{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' }}
@@ -138,6 +138,7 @@ 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"
>
<CachedImage
type="tmdb"
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
@@ -226,7 +227,8 @@ const IssueItem = ({ issue }: IssueItemProps) => {
href={`/users/${issue.createdBy.id}`}
className="group flex items-center truncate"
>
<Image
<CachedImage
type="avatar"
src={issue.createdBy.avatar}
alt=""
className="avatar-sm ml-1.5 object-cover"

View File

@@ -7,6 +7,7 @@ import {
CogIcon,
EllipsisHorizontalIcon,
ExclamationTriangleIcon,
EyeSlashIcon,
FilmIcon,
SparklesIcon,
TvIcon,
@@ -16,6 +17,7 @@ import {
ClockIcon as FilledClockIcon,
CogIcon as FilledCogIcon,
ExclamationTriangleIcon as FilledExclamationTriangleIcon,
EyeSlashIcon as FilledEyeSlashIcon,
FilmIcon as FilledFilmIcon,
SparklesIcon as FilledSparklesIcon,
TvIcon as FilledTvIcon,
@@ -84,6 +86,18 @@ const MobileMenu = () => {
svgIconSelected: <FilledClockIcon className="h-6 w-6" />,
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',
content: intl.formatMessage(menuMessages.issues),

View File

@@ -8,6 +8,7 @@ import {
ClockIcon,
CogIcon,
ExclamationTriangleIcon,
EyeSlashIcon,
FilmIcon,
SparklesIcon,
TvIcon,
@@ -25,6 +26,7 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', {
browsemovies: 'Movies',
browsetv: 'Series',
requests: 'Requests',
blacklist: 'Blacklist',
issues: 'Issues',
users: 'Users',
settings: 'Settings',
@@ -71,6 +73,17 @@ const SidebarLinks: SidebarLinkProps[] = [
svgIcon: <ClockIcon className="mr-3 h-6 w-6" />,
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',
messagesKey: 'issues',

View File

@@ -1,3 +1,4 @@
import CachedImage from '@app/components/Common/CachedImage';
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
@@ -7,7 +8,6 @@ import {
ClockIcon,
} from '@heroicons/react/24/outline';
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
import Image from 'next/image';
import type { LinkProps } from 'next/link';
import Link from 'next/link';
import { forwardRef, Fragment } from 'react';
@@ -56,9 +56,10 @@ 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"
data-testid="user-menu"
>
<Image
<CachedImage
type="avatar"
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user?.avatar || ''}
src={user ? user.avatar : ''}
alt=""
width={40}
height={40}
@@ -79,9 +80,10 @@ 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="flex flex-col space-y-4 px-4 py-4">
<div className="flex items-center space-x-2">
<Image
<CachedImage
type="avatar"
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user?.avatar || ''}
src={user ? user.avatar : ''}
alt=""
width={40}
height={40}

View File

@@ -4,9 +4,9 @@ import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages';
import { InformationCircleIcon } from '@heroicons/react/24/solid';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType, ServerType } from '@server/constants/server';
import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config';
import { useIntl } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
@@ -26,6 +26,7 @@ const messages = defineMessages('components.Login', {
validationemailformat: 'Valid email required',
validationusernamerequired: 'Username required',
validationpasswordrequired: 'Password required',
validationservertyperequired: 'Please select a server type',
validationHostnameRequired: 'You must provide a valid hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
@@ -40,42 +41,51 @@ const messages = defineMessages('components.Login', {
initialsigningin: 'Connecting…',
initialsignin: 'Connect',
forgotpassword: 'Forgot Password?',
servertype: 'Server Type',
back: 'Go back',
});
interface JellyfinLoginProps {
revalidate: () => void;
initial?: boolean;
serverType?: MediaServerType;
onCancel?: () => void;
}
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
revalidate,
initial,
serverType,
onCancel,
}) => {
const toasts = useToasts();
const intl = useIntl();
const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
const mediaServerFormatValues = {
mediaServerName:
serverType === MediaServerType.JELLYFIN
? ServerType.JELLYFIN
: serverType === MediaServerType.EMBY
? ServerType.EMBY
: 'Media Server',
};
if (initial) {
const LoginSchema = Yup.object().shape({
hostname: Yup.string().required(
intl.formatMessage(messages.validationhostrequired, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
})
intl.formatMessage(
messages.validationhostrequired,
mediaServerFormatValues
)
),
port: Yup.number().required(
intl.formatMessage(messages.validationPortRequired)
),
urlBase: Yup.string()
.matches(
/^(\/[^/].*[^/]$)/,
intl.formatMessage(messages.validationUrlBaseLeadingSlash)
)
.matches(
/^(.*[^/])$/,
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
),
urlBase: Yup.string().matches(
/^(.*[^/])$/,
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
),
email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat))
.required(intl.formatMessage(messages.validationemailrequired)),
@@ -85,11 +95,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
password: Yup.string(),
});
const mediaServerFormatValues = {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
};
return (
<Formik
initialValues={{
@@ -104,6 +109,11 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
validationSchema={LoginSchema}
onSubmit={async (values) => {
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', {
method: 'POST',
headers: {
@@ -117,6 +127,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
useSsl: values.useSsl,
urlBase: values.urlBase,
email: values.email,
serverType: serverType,
}),
});
if (!res.ok) throw new Error(res.statusText, { cause: res });
@@ -312,7 +323,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
</div>
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex justify-end">
<div className="flex flex-row-reverse justify-between">
<span className="inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
@@ -324,6 +335,13 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
: intl.formatMessage(messages.signin)}
</Button>
</span>
{onCancel && (
<span className="inline-flex rounded-md shadow-sm">
<Button buttonType="default" onClick={() => onCancel()}>
<FormattedMessage {...messages.back} />
</Button>
</span>
)}
</div>
</div>
</Form>
@@ -429,7 +447,8 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
jellyfinForgotPasswordUrl
? `${jellyfinForgotPasswordUrl}`
: `${baseUrl}/web/index.html#!/${
process.env.JELLYFIN_TYPE === 'emby'
settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? 'startup/'
: ''
}forgotpassword.html`

View File

@@ -10,7 +10,6 @@ import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { XCircleIcon } from '@heroicons/react/24/solid';
import { MediaServerType } from '@server/constants/server';
import getConfig from 'next/config';
import { useRouter } from 'next/dist/client/router';
import Image from 'next/image';
import { useEffect, useState } from 'react';
@@ -34,7 +33,6 @@ const Login = () => {
const { user, revalidate } = useUser();
const router = useRouter();
const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
// 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
@@ -88,6 +86,15 @@ const Login = () => {
revalidateOnFocus: false,
});
const mediaServerFormatValues = {
mediaServerName:
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? 'Jellyfin'
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
? 'Emby'
: undefined,
};
return (
<div className="relative flex min-h-screen flex-col bg-gray-900 py-14">
<PageTitle title={intl.formatMessage(messages.signin)} />
@@ -154,12 +161,10 @@ const Login = () => {
{settings.currentSettings.mediaServerType ==
MediaServerType.PLEX
? intl.formatMessage(messages.signinwithplex)
: intl.formatMessage(messages.signinwithjellyfin, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
})}
: intl.formatMessage(
messages.signinwithjellyfin,
mediaServerFormatValues
)}
</button>
<AccordionContent isOpen={openIndexes.includes(0)}>
<div className="px-10 py-8">

View File

@@ -1,4 +1,6 @@
import BlacklistBlock from '@app/components/BlacklistBlock';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import ConfirmButton from '@app/components/Common/ConfirmButton';
import SlideOver from '@app/components/Common/SlideOver';
import Tooltip from '@app/components/Common/Tooltip';
@@ -26,8 +28,6 @@ import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfa
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import getConfig from 'next/config';
import Image from 'next/image';
import Link from 'next/link';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
@@ -95,7 +95,6 @@ const ManageSlideOver = ({
const { user: currentUser, hasPermission } = useUser();
const intl = useIntl();
const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
const { data: watchData } = useSWR<MediaWatchDataResponse>(
settings.currentSettings.mediaServerType === MediaServerType.PLEX &&
data.mediaInfo &&
@@ -286,6 +285,20 @@ const ManageSlideOver = ({
</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) &&
(data.mediaInfo?.serviceUrl ||
data.mediaInfo?.tautulliUrl ||
@@ -355,7 +368,8 @@ const ManageSlideOver = ({
key={`watch-user-${user.id}`}
content={user.displayName}
>
<Image
<CachedImage
type="avatar"
src={user.avatar}
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"
@@ -516,7 +530,8 @@ const ManageSlideOver = ({
key={`watch-user-${user.id}`}
content={user.displayName}
>
<Image
<CachedImage
type="avatar"
src={user.avatar}
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"
@@ -605,32 +620,17 @@ const ManageSlideOver = ({
</div>
</div>
)}
{hasPermission(Permission.ADMIN) && data?.mediaInfo && (
<div>
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalAdvanced)}
</h3>
<div className="space-y-2">
{data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
<Button
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 && (
{hasPermission(Permission.ADMIN) &&
data?.mediaInfo &&
data.mediaInfo.status !== MediaStatus.BLACKLISTED && (
<div>
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalAdvanced)}
</h3>
<div className="space-y-2">
{data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
<Button
onClick={() => markAvailable(true)}
onClick={() => markAvailable()}
className="w-full"
buttonType="success"
>
@@ -638,41 +638,59 @@ const ManageSlideOver = ({
<span>
{intl.formatMessage(
mediaType === 'movie'
? messages.mark4kavailable
: messages.markallseasons4kavailable
? messages.markavailable
: messages.markallseasonsavailable
)}
</span>
</Button>
)}
<div>
<ConfirmButton
onClick={() => deleteMedia()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<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:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? 'Plex'
: 'Jellyfin',
})}
{data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
settings.currentSettings.series4kEnabled && (
<Button
onClick={() => markAvailable(true)}
className="w-full"
buttonType="success"
>
<CheckCircleIcon />
<span>
{intl.formatMessage(
mediaType === 'movie'
? messages.mark4kavailable
: messages.markallseasons4kavailable
)}
</span>
</Button>
)}
<div>
<ConfirmButton
onClick={() => deleteMedia()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<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>
</SlideOver>
);

View File

@@ -3,8 +3,10 @@ import PersonCard from '@app/components/PersonCard';
import Slider from '@app/components/Slider';
import TitleCard from '@app/components/TitleCard';
import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import { MediaStatus } from '@server/constants/media';
import { Permission } from '@server/lib/permissions';
import type {
MovieResult,
PersonResult,
@@ -41,6 +43,7 @@ const MediaSlider = ({
onNewTitles,
}: MediaSliderProps) => {
const settings = useSettings();
const { hasPermission } = useUser();
const { data, error, setSize, size } = useSWRInfinite<MixedResult>(
(pageIndex: number, previousPageData: MixedResult | null) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
@@ -90,50 +93,65 @@ const MediaSlider = ({
return null;
}
const finalTitles = titles.slice(0, 20).map((title) => {
switch (title.mediaType) {
case 'movie':
const blacklistVisibility = hasPermission(
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
{ type: 'or' }
);
const finalTitles = titles
.slice(0, 20)
.filter((title) => {
if (!blacklistVisibility)
return (
<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}
/>
(title as TvResult | MovieResult).mediaInfo?.status !==
MediaStatus.BLACKLISTED
);
case 'tv':
return (
<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}
/>
);
}
});
return title;
})
.map((title) => {
switch (title.mediaType) {
case 'movie':
return (
<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}
/>
);
case 'tv':
return (
<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) {
finalTitles.push(

View File

@@ -5,6 +5,7 @@ import RTRotten from '@app/assets/rt_rotten.svg';
import ImdbLogo from '@app/assets/services/imdb.svg';
import Spinner from '@app/assets/spinner.svg';
import TmdbLogo from '@app/assets/tmdb_logo.svg';
import BlacklistModal from '@app/components/BlacklistModal';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
@@ -35,6 +36,7 @@ import {
CloudIcon,
CogIcon,
ExclamationTriangleIcon,
EyeSlashIcon,
FilmIcon,
PlayIcon,
TicketIcon,
@@ -53,10 +55,9 @@ import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
import { countries } from 'country-flag-icons';
import 'country-flag-icons/3x2/flags.css';
import { uniqBy } from 'lodash';
import getConfig from 'next/config';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
@@ -126,7 +127,9 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
!movie?.onUserWatchlist
);
const { publicRuntimeConfig } = getConfig();
const [isBlacklistUpdating, setIsBlacklistUpdating] =
useState<boolean>(false);
const [showBlacklistModal, setShowBlacklistModal] = useState(false);
const { addToast } = useToasts();
const {
@@ -157,6 +160,11 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
setShowManager(router.query.manage == '1' ? true : false);
}, [router.query.manage]);
const closeBlacklistModal = useCallback(
() => setShowBlacklistModal(false),
[]
);
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
mediaUrl: data?.mediaInfo?.mediaUrl,
mediaUrl4k: data?.mediaInfo?.mediaUrl4k,
@@ -279,7 +287,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
?.flatrate ?? [];
function getAvalaibleMediaServerName() {
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
}
@@ -291,8 +299,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
}
function getAvalaible4kMediaServerName() {
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
return intl.formatMessage(messages.play4k, { mediaServerName: 'Emby' });
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
}
if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) {
@@ -376,6 +384,60 @@ 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 (
<div
className="media-page"
@@ -386,6 +448,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
{data.backdropPath && (
<div className="media-page-bg-image">
<CachedImage
type="tmdb"
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -421,9 +484,18 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
revalidate={() => revalidate()}
show={showManager}
/>
<BlacklistModal
tmdbId={data.id}
type="movie"
show={showBlacklistModal}
onCancel={closeBlacklistModal}
onComplete={onClickHideItemBtn}
isUpdating={isBlacklistUpdating}
/>
<div className="media-header">
<div className="media-poster">
<CachedImage
type="tmdb"
src={
data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
@@ -497,40 +569,61 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
</span>
</div>
<div className="media-actions">
<>
{toggleWatchlist ? (
<Tooltip content={intl.formatMessage(messages.addtowatchlist)}>
{showHideButton &&
data?.mediaInfo?.status !== MediaStatus.PROCESSING &&
data?.mediaInfo?.status !== MediaStatus.AVAILABLE &&
data?.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE &&
data?.mediaInfo?.status !== MediaStatus.PENDING &&
data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && (
<Tooltip
content={intl.formatMessage(globalMessages.addToBlacklist)}
>
<Button
buttonType={'ghost'}
className="z-40 mr-2"
buttonSize={'md'}
onClick={onClickWatchlistBtn}
onClick={() => setShowBlacklistModal(true)}
>
{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'} />
)}
<EyeSlashIcon className={'h-3'} />
</Button>
</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} />
<RequestButton
mediaType="movie"
@@ -650,6 +743,7 @@ 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="absolute inset-0 z-0">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath}`}
alt=""
style={{

View File

@@ -78,6 +78,13 @@ export const messages = defineMessages('components.PermissionEdit', {
viewwatchlists: 'View {mediaServerName} Watchlists',
viewwatchlistsDescription:
"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 {
@@ -332,6 +339,22 @@ 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 (

View File

@@ -51,6 +51,7 @@ const PersonCard = ({
{profilePath ? (
<div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
alt=""
style={{

View File

@@ -227,6 +227,7 @@ const PersonDetails = () => {
{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">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath}`}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}

View File

@@ -300,6 +300,7 @@ const RequestButton = ({
}) &&
media &&
media.status !== MediaStatus.AVAILABLE &&
media.status !== MediaStatus.BLACKLISTED &&
!isShowComplete
) {
buttons.push({
@@ -345,6 +346,7 @@ const RequestButton = ({
}) &&
media &&
media.status4k !== MediaStatus.AVAILABLE &&
media.status !== MediaStatus.BLACKLISTED &&
!is4kShowComplete &&
settings.currentSettings.series4kEnabled
) {

View File

@@ -22,7 +22,6 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import Image from 'next/image';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer';
@@ -116,7 +115,8 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
className="group flex items-center"
>
<span className="avatar-sm">
<Image
<CachedImage
type="avatar"
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm object-cover"
@@ -346,6 +346,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
{title.backdropPath && (
<div className="absolute inset-0 z-0">
<CachedImage
type="tmdb"
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -390,7 +391,8 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
className="group flex items-center"
>
<span className="avatar-sm">
<Image
<CachedImage
type="avatar"
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm object-cover"
@@ -603,6 +605,7 @@ 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"
>
<CachedImage
type="tmdb"
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`

View File

@@ -21,7 +21,6 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import Image from 'next/image';
import Link from 'next/link';
import { useState } from 'react';
import { useInView } from 'react-intersection-observer';
@@ -43,6 +42,7 @@ const messages = defineMessages('components.RequestList.RequestItem', {
tmdbid: 'TMDB ID',
tvdbid: 'TheTVDB ID',
unknowntitle: 'Unknown Title',
removearr: 'Remove from {arr}',
profileName: 'Profile',
});
@@ -190,7 +190,8 @@ const RequestItemError = ({
className="group flex items-center truncate"
>
<span className="avatar-sm ml-1.5">
<Image
<CachedImage
type="avatar"
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm object-cover"
@@ -249,7 +250,8 @@ const RequestItemError = ({
className="group flex items-center truncate"
>
<span className="avatar-sm ml-1.5">
<Image
<CachedImage
type="avatar"
src={requestData.modifiedBy.avatar}
alt=""
className="avatar-sm object-cover"
@@ -342,6 +344,18 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
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 () => {
setRetrying(true);
@@ -406,6 +420,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
{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' }}
@@ -431,6 +446,7 @@ 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"
>
<CachedImage
type="tmdb"
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
@@ -557,7 +573,8 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
className="group flex items-center truncate"
>
<span className="avatar-sm ml-1.5">
<Image
<CachedImage
type="avatar"
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm object-cover"
@@ -616,8 +633,9 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
className="group flex items-center truncate"
>
<span className="avatar-sm ml-1.5">
<Image
src={requestData.requestedBy.avatar}
<CachedImage
type="avatar"
src={requestData.modifiedBy.avatar}
alt=""
className="avatar-sm object-cover"
width={20}
@@ -667,14 +685,28 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
)}
{requestData.status !== MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<ConfirmButton
onClick={() => deleteRequest()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
</ConfirmButton>
<>
<ConfirmButton
onClick={() => deleteRequest()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
</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 &&
hasPermission(Permission.MANAGE_REQUESTS) && (

View File

@@ -1,4 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */
import CachedImage from '@app/components/Common/CachedImage';
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import type { User } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
@@ -14,7 +15,6 @@ import type {
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import { hasPermission } from '@server/lib/permissions';
import { isEqual } from 'lodash';
import Image from 'next/image';
import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import Select from 'react-select';
@@ -561,7 +561,8 @@ const AdvancedRequester = ({
<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">
<span className="flex items-center">
<Image
<CachedImage
type="avatar"
src={selectedUser.avatar}
alt=""
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
@@ -613,7 +614,8 @@ const AdvancedRequester = ({
selected ? 'font-semibold' : 'font-normal'
} flex items-center`}
>
<Image
<CachedImage
type="avatar"
src={user.avatar}
alt=""
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"

View File

@@ -66,7 +66,9 @@ const CollectionRequestModal = ({
(quota?.movie.remaining ?? 0) - selectedParts.length;
const getAllParts = (): number[] => {
return (data?.parts ?? []).map((part) => part.id);
return (data?.parts ?? [])
.filter((part) => part.mediaInfo?.status !== MediaStatus.BLACKLISTED)
.map((part) => part.id);
};
const getAllRequestedParts = (): number[] => {
@@ -248,6 +250,11 @@ const CollectionRequestModal = ({
{ type: 'or' }
);
const blacklistVisibility = hasPermission(
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
{ type: 'or' }
);
return (
<Modal
loading={(!data && !error) || !quota}
@@ -344,122 +351,157 @@ const CollectionRequestModal = ({
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{data?.parts.map((part) => {
const partRequest = getPartRequest(part.id);
const partMedia =
part.mediaInfo &&
part.mediaInfo[is4k ? 'status4k' : 'status'] !==
MediaStatus.UNKNOWN
? part.mediaInfo
: undefined;
{data?.parts
.filter((part) => {
if (!blacklistVisibility)
return (
part.mediaInfo?.status !== MediaStatus.BLACKLISTED
);
return part;
})
.map((part) => {
const partRequest = getPartRequest(part.id);
const partMedia =
part.mediaInfo &&
part.mediaInfo[is4k ? 'status4k' : 'status'] !==
MediaStatus.UNKNOWN
? part.mediaInfo
: undefined;
return (
<tr key={`part-${part.id}`}>
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
<span
role="checkbox"
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'
: ''
return (
<tr key={`part-${part.id}`}>
<td
className={`whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100 ${
partMedia?.status === MediaStatus.BLACKLISTED &&
'pointer-events-none opacity-50'
}`}
>
<span
aria-hidden="true"
className={`${
!!partMedia ||
partRequest ||
role="checkbox"
tabIndex={0}
aria-checked={
(!!partMedia &&
partMedia.status !==
MediaStatus.BLACKLISTED) ||
isSelectedPart(part.id)
? 'bg-indigo-500'
: 'bg-gray-700'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
!!partMedia ||
partRequest ||
isSelectedPart(part.id)
? 'translate-x-5'
: 'translate-x-0'
} 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`}
></span>
</span>
</td>
<td className="flex items-center px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
<div className="relative h-auto w-10 flex-shrink-0 overflow-hidden rounded-md">
<CachedImage
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',
onClick={() => togglePart(part.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
togglePart(part.id);
}
}}
width={600}
height={900}
/>
</div>
<div className="flex flex-col justify-center pl-2">
<div className="text-xs font-medium">
{part.releaseDate?.slice(0, 4)}
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 ||
(quota?.movie.limit &&
currentlyRemaining <= 0 &&
!isSelectedPart(part.id))
? 'opacity-50'
: ''
}`}
>
<span
aria-hidden="true"
className={`${
(!!partMedia &&
partMedia.status !==
MediaStatus.BLACKLISTED) ||
partRequest ||
isSelectedPart(part.id)
? 'bg-indigo-500'
: 'bg-gray-700'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
(!!partMedia &&
partMedia.status !==
MediaStatus.BLACKLISTED) ||
partRequest ||
isSelectedPart(part.id)
? 'translate-x-5'
: 'translate-x-0'
} 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`}
></span>
</span>
</td>
<td
className={`flex items-center px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6 ${
partMedia?.status === MediaStatus.BLACKLISTED &&
'pointer-events-none opacity-50'
}`}
>
<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 className="text-base font-bold">
{part.title}
<div className="flex flex-col justify-center pl-2">
<div className="text-xs font-medium">
{part.releaseDate?.slice(0, 4)}
</div>
<div className="text-base font-bold">
{part.title}
</div>
</div>
</div>
</td>
<td className="whitespace-nowrap py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6">
{!partMedia && !partRequest && (
<Badge>
{intl.formatMessage(globalMessages.notrequested)}
</Badge>
)}
{!partMedia &&
partRequest?.status ===
MediaRequestStatus.PENDING && (
<Badge badgeType="warning">
{intl.formatMessage(globalMessages.pending)}
</td>
<td className="whitespace-nowrap py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6">
{!partMedia && !partRequest && (
<Badge>
{intl.formatMessage(
globalMessages.notrequested
)}
</Badge>
)}
{((!partMedia &&
partRequest?.status ===
MediaRequestStatus.APPROVED) ||
partMedia?.[is4k ? 'status4k' : 'status'] ===
MediaStatus.PROCESSING) && (
<Badge badgeType="primary">
{intl.formatMessage(globalMessages.requested)}
</Badge>
)}
{partMedia?.[is4k ? 'status4k' : 'status'] ===
MediaStatus.AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
</Badge>
)}
</td>
</tr>
);
})}
{!partMedia &&
partRequest?.status ===
MediaRequestStatus.PENDING && (
<Badge badgeType="warning">
{intl.formatMessage(globalMessages.pending)}
</Badge>
)}
{((!partMedia &&
partRequest?.status ===
MediaRequestStatus.APPROVED) ||
partMedia?.[is4k ? 'status4k' : 'status'] ===
MediaStatus.PROCESSING) && (
<Badge badgeType="primary">
{intl.formatMessage(globalMessages.requested)}
</Badge>
)}
{partMedia?.[is4k ? 'status4k' : 'status'] ===
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>
</table>
</div>

View File

@@ -33,6 +33,13 @@ const messages = defineMessages('components.Selector', {
nooptions: 'No results.',
showmore: 'Show More',
showless: 'Show Less',
searchStatus: 'Select status...',
returningSeries: 'Returning Series',
planned: 'Planned',
inProduction: 'In Production',
ended: 'Ended',
canceled: 'Canceled',
pilot: 'Pilot',
});
type SingleVal = {
@@ -204,6 +211,75 @@ 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 = ({
isMulti,
defaultValue,
@@ -376,6 +452,7 @@ export const WatchProviderSelector = ({
tabIndex={0}
>
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
style={{
@@ -421,6 +498,7 @@ export const WatchProviderSelector = ({
tabIndex={0}
>
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
style={{

View File

@@ -3,13 +3,14 @@ import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import LibraryItem from '@app/components/Settings/LibraryItem';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType } from '@server/constants/server';
import type { JellyfinSettings } from '@server/lib/settings';
import { Field, Formik } from 'formik';
import getConfig from 'next/config';
import { useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
@@ -61,6 +62,9 @@ const messages = defineMessages('components.Settings', {
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationUrlBaseLeadingSlash: 'URL base must have a leading 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 {
@@ -78,13 +82,13 @@ interface SyncStatus {
}
interface SettingsJellyfinProps {
showAdvancedSettings?: boolean;
isSetupSettings?: boolean;
onComplete?: () => void;
}
const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
onComplete,
showAdvancedSettings,
isSetupSettings,
}) => {
const [isSyncing, setIsSyncing] = useState(false);
const toasts = useToasts();
@@ -102,7 +106,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
);
const intl = useIntl();
const { addToast } = useToasts();
const { publicRuntimeConfig } = getConfig();
const settings = useSettings();
const JellyfinSettingsSchema = Yup.object().shape({
hostname: Yup.string()
@@ -284,26 +288,29 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
return <LoadingSpinner />;
}
const mediaServerFormatValues = {
mediaServerName:
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? 'Jellyfin'
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
? 'Emby'
: undefined,
};
return (
<>
<div className="mb-6">
<h3 className="heading">
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? intl.formatMessage(messages.jellyfinlibraries, {
mediaServerName: 'Emby',
})
: intl.formatMessage(messages.jellyfinlibraries, {
mediaServerName: 'Jellyfin',
})}
{intl.formatMessage(
messages.jellyfinlibraries,
mediaServerFormatValues
)}
</h3>
<p className="description">
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? intl.formatMessage(messages.jellyfinlibrariesDescription, {
mediaServerName: 'Emby',
})
: intl.formatMessage(messages.jellyfinlibrariesDescription, {
mediaServerName: 'Jellyfin',
})}
{intl.formatMessage(
messages.jellyfinlibrariesDescription,
mediaServerFormatValues
)}
</p>
</div>
<div className="section">
@@ -340,13 +347,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
<FormattedMessage {...messages.manualscanJellyfin} />
</h3>
<p className="description">
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? intl.formatMessage(messages.manualscanDescriptionJellyfin, {
mediaServerName: 'Emby',
})
: intl.formatMessage(messages.manualscanDescriptionJellyfin, {
mediaServerName: 'Jellyfin',
})}
{intl.formatMessage(
messages.manualscanDescriptionJellyfin,
mediaServerFormatValues
)}
</p>
</div>
<div className="section">
@@ -446,24 +450,26 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
</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">
<h3 className="heading">
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? intl.formatMessage(messages.jellyfinSettings, {
mediaServerName: 'Emby',
})
: intl.formatMessage(messages.jellyfinSettings, {
mediaServerName: 'Jellyfin',
})}
{intl.formatMessage(
messages.jellyfinSettings,
mediaServerFormatValues
)}
</h3>
<p className="description">
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? intl.formatMessage(messages.jellyfinSettingsDescription, {
mediaServerName: 'Emby',
})
: intl.formatMessage(messages.jellyfinSettingsDescription, {
mediaServerName: 'Jellyfin',
})}
{intl.formatMessage(
messages.jellyfinSettingsDescription,
mediaServerFormatValues
)}
</p>
</div>
<Formik
@@ -497,12 +503,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
if (!res.ok) throw new Error(res.statusText, { cause: res });
addToast(
intl.formatMessage(messages.jellyfinSettingsSuccess, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
intl.formatMessage(
messages.jellyfinSettingsSuccess,
mediaServerFormatValues
),
{
autoDismiss: true,
appearance: 'success',
@@ -518,12 +522,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
}
if (errorData?.message === ApiErrorCode.InvalidUrl) {
addToast(
intl.formatMessage(messages.invalidurlerror, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
intl.formatMessage(
messages.invalidurlerror,
mediaServerFormatValues
),
{
autoDismiss: true,
appearance: 'error',
@@ -531,12 +533,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
);
} else {
addToast(
intl.formatMessage(messages.jellyfinSettingsFailure, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
intl.formatMessage(
messages.jellyfinSettingsFailure,
mediaServerFormatValues
),
{
autoDismiss: true,
appearance: 'error',
@@ -559,7 +559,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
}) => {
return (
<form className="section" onSubmit={handleSubmit}>
{showAdvancedSettings && (
{!isSetupSettings && (
<>
<div className="form-row">
<label htmlFor="hostname" className="text-label">
@@ -643,7 +643,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
)}
</div>
</div>
{showAdvancedSettings && (
{!isSetupSettings && (
<>
<div className="form-row">
<label htmlFor="urlBase" className="text-label">
@@ -710,7 +710,9 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
)}
</div>
</div>
<div className="actions">
<div
className={`actions ${isSetupSettings ? 'mt-0 border-0' : ''}`}
>
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button

View File

@@ -7,6 +7,7 @@ import PageTitle from '@app/components/Common/PageTitle';
import Table from '@app/components/Common/Table';
import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { formatBytes } from '@app/utils/numberHelpers';
@@ -57,8 +58,8 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
'plex-recently-added-scan': 'Plex Recently Added Scan',
'plex-full-scan': 'Plex Full Library Scan',
'plex-watchlist-sync': 'Plex Watchlist Sync',
'jellyfin-recently-added-scan': 'Jellyfin Recently Added Scan',
'jellyfin-full-scan': 'Jellyfin Full Library Scan',
'jellyfin-recently-added-scan': 'Jellyfin Recently Added Scan',
'availability-sync': 'Media Availability Sync',
'radarr-scan': 'Radarr Scan',
'sonarr-scan': 'Sonarr Scan',
@@ -81,6 +82,7 @@ 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>.',
imagecachecount: 'Images Cached',
imagecachesize: 'Total Cache Size',
usersavatars: "Users' Avatars",
}
);
@@ -167,6 +169,20 @@ const SettingsJobs = () => {
const [isSaving, setIsSaving] = useState(false);
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) {
return <LoadingSpinner />;
}
@@ -558,6 +574,19 @@ const SettingsJobs = () => {
{formatBytes(cacheData?.imageCache.tmdb.size ?? 0)}
</Table.TD>
</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>
</div>

View File

@@ -5,7 +5,6 @@ import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { MediaServerType } from '@server/constants/server';
import getConfig from 'next/config';
import { useIntl } from 'react-intl';
const messages = defineMessages('components.Settings', {
@@ -26,7 +25,6 @@ type SettingsLayoutProps = {
const SettingsLayout = ({ children }: SettingsLayoutProps) => {
const intl = useIntl();
const { publicRuntimeConfig } = getConfig();
const settings = useSettings();
const settingsRoutes: SettingsRoute[] = [
{
@@ -89,7 +87,11 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => {
function getAvailableMediaServerName() {
return intl.formatMessage(messages.menuJellyfinSettings, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE === 'emby' ? 'Emby' : 'Jellyfin',
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? 'Jellyfin'
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
? 'Emby'
: undefined,
});
}
};

View File

@@ -55,6 +55,8 @@ const messages = defineMessages('components.Settings.SettingsMain', {
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
partialRequestsEnabled: 'Allow Partial Series Requests',
locale: 'Display Language',
httpProxy: 'HTTP Proxy',
httpProxyTip: 'Tooltip to write',
});
const SettingsMain = () => {
@@ -82,6 +84,9 @@ const SettingsMain = () => {
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
(value) => !value || !value.endsWith('/')
),
httpProxy: Yup.string().url(
intl.formatMessage(messages.validationApplicationUrl)
),
});
const regenerate = async () => {
@@ -137,6 +142,7 @@ const SettingsMain = () => {
partialRequestsEnabled: data?.partialRequestsEnabled,
trustProxy: data?.trustProxy,
cacheImages: data?.cacheImages,
httpProxy: data?.httpProxy,
}}
enableReinitialize
validationSchema={MainSettingsSchema}
@@ -158,6 +164,7 @@ const SettingsMain = () => {
partialRequestsEnabled: values.partialRequestsEnabled,
trustProxy: values.trustProxy,
cacheImages: values.cacheImages,
httpProxy: values.httpProxy,
}),
});
if (!res.ok) throw new Error();
@@ -437,6 +444,28 @@ const SettingsMain = () => {
/>
</div>
</div>
<div className="form-row">
<label htmlFor="httpProxy" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.httpProxy)}
</span>
<SettingsBadge badgeType="advanced" className="mr-2" />
<SettingsBadge badgeType="restartRequired" />
<span className="label-tip">
{intl.formatMessage(messages.httpProxyTip)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="httpProxy" name="httpProxy" type="text" />
</div>
{errors.httpProxy &&
touched.httpProxy &&
typeof errors.httpProxy === 'string' && (
<div className="error">{errors.httpProxy}</div>
)}
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">

View File

@@ -10,7 +10,6 @@ import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import { MediaServerType } from '@server/constants/server';
import type { MainSettings } from '@server/lib/settings';
import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr';
@@ -42,12 +41,20 @@ const SettingsUsers = () => {
mutate: revalidate,
} = useSWR<MainSettings>('/api/v1/settings/main');
const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
if (!data && !error) {
return <LoadingSpinner />;
}
const mediaServerFormatValues = {
mediaServerName:
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? 'Jellyfin'
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
? 'Emby'
: undefined,
};
return (
<>
<PageTitle
@@ -121,16 +128,10 @@ const SettingsUsers = () => {
<label htmlFor="localLogin" className="checkbox-label">
{intl.formatMessage(messages.localLogin)}
<span className="label-tip">
{intl.formatMessage(messages.localLoginTip, {
mediaServerName:
settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? 'Plex'
: settings.currentSettings.mediaServerType ===
MediaServerType.JELLYFIN
? 'Jellyfin'
: 'Emby',
})}
{intl.formatMessage(
messages.localLoginTip,
mediaServerFormatValues
)}
</span>
</label>
<div className="form-input-area">
@@ -146,25 +147,15 @@ const SettingsUsers = () => {
</div>
<div className="form-row">
<label htmlFor="newPlexLogin" className="checkbox-label">
{intl.formatMessage(messages.newPlexLogin, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? 'Plex'
: 'Jellyfin',
})}
{intl.formatMessage(
messages.newPlexLogin,
mediaServerFormatValues
)}
<span className="label-tip">
{intl.formatMessage(messages.newPlexLoginTip, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? 'Plex'
: 'Jellyfin',
})}
{intl.formatMessage(
messages.newPlexLoginTip,
mediaServerFormatValues
)}
</span>
</label>
<div className="form-input-area">

View File

@@ -1,32 +1,39 @@
import Accordion from '@app/components/Common/Accordion';
import Button from '@app/components/Common/Button';
import JellyfinLogin from '@app/components/Login/JellyfinLogin';
import PlexLoginButton from '@app/components/PlexLoginButton';
import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { MediaServerType } from '@server/constants/server';
import getConfig from 'next/config';
import { useEffect, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { FormattedMessage } from 'react-intl';
const messages = defineMessages('components.Setup', {
welcome: 'Welcome to Jellyseerr',
signinMessage: 'Get started by signing in',
signinWithJellyfin: 'Use your {mediaServerName} account',
signinWithPlex: 'Use your Plex account',
signin: 'Sign in to your account',
signinWithJellyfin: 'Enter your Jellyfin details',
signinWithEmby: 'Enter your Emby details',
signinWithPlex: 'Enter your Plex details',
back: 'Go back',
});
interface LoginWithMediaServerProps {
onComplete: (onComplete: MediaServerType) => void;
serverType: MediaServerType;
onCancel: () => void;
onComplete: () => void;
}
const SetupLogin: React.FC<LoginWithMediaServerProps> = ({ onComplete }) => {
const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
serverType,
onCancel,
onComplete,
}) => {
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
const [mediaServerType, setMediaServerType] = useState<MediaServerType>(
MediaServerType.NOT_CONFIGURED
);
const { user, revalidate } = useUser();
const intl = useIntl();
const { publicRuntimeConfig } = getConfig();
// 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
// ask swr to revalidate the user which _shouid_ come back with a valid user.
@@ -56,71 +63,60 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({ onComplete }) => {
useEffect(() => {
if (user) {
onComplete(mediaServerType);
onComplete();
}
}, [user, mediaServerType, onComplete]);
return (
<div>
<div className="p-4">
<div className="mb-2 flex justify-center text-xl font-bold">
<FormattedMessage {...messages.welcome} />
<FormattedMessage {...messages.signin} />
</div>
<div className="mb-2 flex justify-center pb-6 text-sm">
<FormattedMessage {...messages.signinMessage} />
</div>
<Accordion single atLeastOne>
{({ openIndexes, handleClick, AccordionContent }) => (
<>
<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 sm:rounded-t-lg ${
openIndexes.includes(0) && 'text-indigo-500'
} ${openIndexes.includes(1) && 'border-b border-gray-500'}`}
onClick={() => handleClick(0)}
>
<FormattedMessage {...messages.signinWithPlex} />
</button>
<AccordionContent isOpen={openIndexes.includes(0)}>
<div
className="px-10 py-8"
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
>
<PlexLoginButton
onAuthToken={(authToken) => {
setMediaServerType(MediaServerType.PLEX);
setAuthToken(authToken);
}}
/>
</div>
</AccordionContent>
<div>
<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)
? 'text-indigo-500'
: 'sm:rounded-b-lg'
}`}
onClick={() => handleClick(1)}
>
{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>
</>
{serverType === MediaServerType.JELLYFIN ? (
<FormattedMessage {...messages.signinWithJellyfin} />
) : serverType === MediaServerType.EMBY ? (
<FormattedMessage {...messages.signinWithEmby} />
) : (
<FormattedMessage {...messages.signinWithPlex} />
)}
</Accordion>
</div>
{serverType === MediaServerType.PLEX && (
<>
<div
className="px-10 py-8"
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
>
<PlexLoginButton
onAuthToken={(authToken) => {
setMediaServerType(MediaServerType.PLEX);
setAuthToken(authToken);
}}
/>
</div>
<div className="mt-4">
<Button buttonType="default" onClick={() => onCancel()}>
<FormattedMessage {...messages.back} />
</Button>
</div>
</>
)}
{serverType === MediaServerType.JELLYFIN && (
<JellyfinLogin
initial={true}
revalidate={revalidate}
serverType={serverType}
onCancel={onCancel}
/>
)}
{serverType === MediaServerType.EMBY && (
<JellyfinLogin
initial={true}
revalidate={revalidate}
serverType={serverType}
onCancel={onCancel}
/>
)}
</div>
);
};

View File

@@ -1,5 +1,7 @@
import EmbyLogo from '@app/assets/services/emby.svg';
import JellyfinLogo from '@app/assets/services/jellyfin.svg';
import PlexLogo from '@app/assets/services/plex.svg';
import AppDataWarning from '@app/components/AppDataWarning';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import ImageFader from '@app/components/Common/ImageFader';
import PageTitle from '@app/components/Common/PageTitle';
@@ -9,26 +11,30 @@ import SettingsPlex from '@app/components/Settings/SettingsPlex';
import SettingsServices from '@app/components/Settings/SettingsServices';
import SetupSteps from '@app/components/Setup/SetupSteps';
import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages';
import { MediaServerType } from '@server/constants/server';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import useSWR, { mutate } from 'swr';
import SetupLogin from './SetupLogin';
const messages = defineMessages('components.Setup', {
welcome: 'Welcome to Jellyseerr',
subtitle: 'Get started by choosing your media server',
configjellyfin: 'Configure Jellyfin',
configplex: 'Configure Plex',
configemby: 'Configure Emby',
setup: 'Setup',
finish: 'Finish Setup',
finishing: 'Finishing…',
continue: 'Continue',
servertype: 'Choose Server Type',
signin: 'Sign In',
configuremediaserver: 'Configure Media Server',
configureservices: 'Configure Services',
tip: 'Tip',
scanbackground:
'Scanning will run in the background. You can continue the setup process in the meantime.',
});
const Setup = () => {
@@ -42,6 +48,7 @@ const Setup = () => {
);
const router = useRouter();
const { locale } = useLocale();
const settings = useSettings();
const finishSetup = async () => {
setIsUpdating(true);
@@ -76,6 +83,25 @@ const Setup = () => {
revalidateOnFocus: false,
});
useEffect(() => {
if (settings.currentSettings.initialized) {
router.push('/');
}
if (
settings.currentSettings.mediaServerType !==
MediaServerType.NOT_CONFIGURED
) {
setCurrentStep(3);
setMediaServerType(settings.currentSettings.mediaServerType);
}
}, [
settings.currentSettings.mediaServerType,
settings.currentSettings.initialized,
router,
]);
if (settings.currentSettings.initialized) return <></>;
return (
<div className="relative flex min-h-screen flex-col justify-center bg-gray-900 py-12">
<PageTitle title={intl.formatMessage(messages.setup)} />
@@ -101,58 +127,120 @@ const Setup = () => {
>
<SetupSteps
stepNumber={1}
description={intl.formatMessage(messages.signin)}
description={intl.formatMessage(messages.servertype)}
active={currentStep === 1}
completed={currentStep > 1}
/>
<SetupSteps
stepNumber={2}
description={intl.formatMessage(messages.configuremediaserver)}
description={intl.formatMessage(messages.signin)}
active={currentStep === 2}
completed={currentStep > 2}
/>
<SetupSteps
stepNumber={3}
description={intl.formatMessage(messages.configureservices)}
description={intl.formatMessage(messages.configuremediaserver)}
active={currentStep === 3}
completed={currentStep > 3}
/>
<SetupSteps
stepNumber={4}
description={intl.formatMessage(messages.configureservices)}
active={currentStep === 4}
isLastStep
/>
</ul>
</nav>
<div className="mt-10 w-full rounded-md border border-gray-600 bg-gray-800 bg-opacity-50 p-4 text-white">
{currentStep === 1 && (
<SetupLogin
onComplete={(mServerType) => {
setMediaServerType(mServerType);
setCurrentStep(2);
}}
/>
<div className="flex flex-col items-center pb-6">
<div className="mb-2 flex justify-center text-xl font-bold">
{intl.formatMessage(messages.welcome)}
</div>
<div className="mb-2 flex justify-center pb-6 text-sm">
{intl.formatMessage(messages.subtitle)}
</div>
<div className="grid grid-cols-3">
<div className="flex flex-col divide-y divide-gray-600 rounded-l border border-gray-600 py-2">
<div className="mb-2 flex flex-1 items-center justify-center py-2 px-2">
<JellyfinLogo className="h-10" />
</div>
<div className="px-2 pt-2">
<button
onClick={() => {
setMediaServerType(MediaServerType.JELLYFIN);
setCurrentStep(2);
}}
className="button-md relative z-10 inline-flex h-full w-full items-center justify-center rounded-md border border-gray-600 bg-transparent px-4 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 hover:border-gray-200 focus:z-20 focus:border-gray-100 focus:outline-none active:border-gray-100"
>
{intl.formatMessage(messages.configjellyfin)}
</button>
</div>
</div>
<div className="flex flex-col divide-y divide-gray-600 border-y border-gray-600 py-2">
<div className="mb-2 flex flex-1 items-center justify-center py-2 px-2">
<PlexLogo className="h-8" />
</div>
<div className="px-2 pt-2">
<button
onClick={() => {
setMediaServerType(MediaServerType.PLEX);
setCurrentStep(2);
}}
className="button-md relative z-10 inline-flex h-full w-full items-center justify-center rounded-md border border-gray-600 bg-transparent px-4 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 hover:border-gray-200 focus:z-20 focus:border-gray-100 focus:outline-none active:border-gray-100"
>
{intl.formatMessage(messages.configplex)}
</button>
</div>
</div>
<div className="flex flex-col divide-y divide-gray-600 rounded-r border border-gray-600 py-2">
<div className="mb-2 flex flex-1 items-center justify-center py-2 px-2">
<EmbyLogo className="h-9" />
</div>
<div className="px-2 pt-2">
<button
onClick={() => {
setMediaServerType(MediaServerType.EMBY);
setCurrentStep(2);
}}
className="button-md relative z-10 inline-flex h-full w-full items-center justify-center rounded-md border border-gray-600 bg-transparent px-4 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 hover:border-gray-200 focus:z-20 focus:border-gray-100 focus:outline-none active:border-gray-100"
>
{intl.formatMessage(messages.configemby)}
</button>
</div>
</div>
</div>
</div>
)}
{currentStep === 2 && (
<div>
<SetupLogin
serverType={mediaServerType}
onCancel={() => {
setMediaServerType(MediaServerType.NOT_CONFIGURED);
setCurrentStep(1);
}}
onComplete={() => setCurrentStep(3)}
/>
)}
{currentStep === 3 && (
<div className="p-2">
{mediaServerType === MediaServerType.PLEX ? (
<SettingsPlex
onComplete={() => setMediaServerSettingsComplete(true)}
/>
) : (
<SettingsJellyfin
showAdvancedSettings={false}
isSetupSettings
onComplete={() => setMediaServerSettingsComplete(true)}
/>
)}
<div className="mt-4 text-sm text-gray-500">
<span className="mr-2">
<Badge>{intl.formatMessage(messages.tip)}</Badge>
</span>
{intl.formatMessage(messages.scanbackground)}
</div>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
disabled={!mediaServerSettingsComplete}
onClick={() => setCurrentStep(3)}
onClick={() => setCurrentStep(4)}
>
{intl.formatMessage(messages.continue)}
</Button>
@@ -161,7 +249,7 @@ const Setup = () => {
</div>
</div>
)}
{currentStep === 3 && (
{currentStep === 4 && (
<div>
<SettingsServices />
<div className="actions">

View File

@@ -9,7 +9,6 @@ import defineMessages from '@app/utils/defineMessages';
import { MediaStatus } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import type { DownloadingItem } from '@server/lib/downloadtracker';
import getConfig from 'next/config';
import { useIntl } from 'react-intl';
const messages = defineMessages('components.StatusBadge', {
@@ -48,7 +47,6 @@ const StatusBadge = ({
const intl = useIntl();
const { hasPermission } = useUser();
const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
let mediaLink: string | undefined;
let mediaLinkDescription: string | undefined;
@@ -86,7 +84,7 @@ const StatusBadge = ({
mediaLink = plexUrl;
mediaLinkDescription = intl.formatMessage(messages.playonplex, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
settings.currentSettings.mediaServerType === MediaServerType.EMBY
? 'Emby'
: settings.currentSettings.mediaServerType === MediaServerType.PLEX
? 'Plex'
@@ -362,6 +360,17 @@ const StatusBadge = ({
</Tooltip>
);
case MediaStatus.BLACKLISTED:
return (
<Tooltip content={mediaLinkDescription}>
<Badge badgeType="danger" href={mediaLink}>
{intl.formatMessage(is4k ? messages.status4k : messages.status, {
status: intl.formatMessage(globalMessages.blacklisted),
})}
</Badge>
</Tooltip>
);
default:
return null;
}

View File

@@ -1,7 +1,9 @@
import Spinner from '@app/assets/spinner.svg';
import BlacklistModal from '@app/components/BlacklistModal';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import StatusBadgeMini from '@app/components/Common/StatusBadgeMini';
import Tooltip from '@app/components/Common/Tooltip';
import RequestModal from '@app/components/RequestModal';
import ErrorCard from '@app/components/TitleCard/ErrorCard';
import Placeholder from '@app/components/TitleCard/Placeholder';
@@ -13,6 +15,8 @@ import { withProperties } from '@app/utils/typeHelpers';
import { Transition } from '@headlessui/react';
import {
ArrowDownTrayIcon,
EyeIcon,
EyeSlashIcon,
MinusCircleIcon,
StarIcon,
} from '@heroicons/react/24/outline';
@@ -20,7 +24,7 @@ import { MediaStatus } from '@server/constants/media';
import type { Watchlist } from '@server/entity/Watchlist';
import type { MediaType } from '@server/models/Search';
import Link from 'next/link';
import { Fragment, useCallback, useEffect, useState } from 'react';
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import { mutate } from 'swr';
@@ -65,7 +69,7 @@ const TitleCard = ({
}: TitleCardProps) => {
const isTouch = useIsTouch();
const intl = useIntl();
const { hasPermission } = useUser();
const { user, hasPermission } = useUser();
const [isUpdating, setIsUpdating] = useState(false);
const [currentStatus, setCurrentStatus] = useState(status);
const [showDetail, setShowDetail] = useState(false);
@@ -74,6 +78,8 @@ const TitleCard = ({
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
!isAddedToWatchlist
);
const [showBlacklistModal, setShowBlacklistModal] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
// Just to get the year from the date
if (year) {
@@ -94,6 +100,11 @@ const TitleCard = ({
[]
);
const closeBlacklistModal = useCallback(
() => setShowBlacklistModal(false),
[]
);
const onClickWatchlistBtn = async (): Promise<void> => {
setIsUpdating(true);
try {
@@ -166,6 +177,99 @@ const TitleCard = ({
}
};
const onClickHideItemBtn = async (): Promise<void> => {
setIsUpdating(true);
const topNode = cardRef.current;
if (topNode) {
const res = await fetch('/api/v1/blacklist', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
tmdbId: id,
mediaType,
title,
user: user?.id,
}),
});
if (res.status === 201) {
addToast(
<span>
{intl.formatMessage(globalMessages.blacklistSuccess, {
title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
setCurrentStatus(MediaStatus.BLACKLISTED);
} else if (res.status === 412) {
addToast(
<span>
{intl.formatMessage(globalMessages.blacklistDuplicateError, {
title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'info', autoDismiss: true }
);
} else {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,
});
}
setIsUpdating(false);
closeBlacklistModal();
} else {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,
});
}
};
const onClickShowBlacklistBtn = async (): Promise<void> => {
setIsUpdating(true);
const topNode = cardRef.current;
if (topNode) {
const res = await fetch('/api/v1/blacklist/' + id, {
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 }
);
setCurrentStatus(MediaStatus.UNKNOWN);
} else {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,
});
}
} else {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,
});
}
setIsUpdating(false);
};
const closeModal = useCallback(() => setShowRequestModal(false), []);
const showRequestButton = hasPermission(
@@ -178,10 +282,15 @@ const TitleCard = ({
{ type: 'or' }
);
const showHideButton = hasPermission([Permission.MANAGE_BLACKLIST], {
type: 'or',
});
return (
<div
className={canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'}
data-testid="title-card"
ref={cardRef}
>
<RequestModal
tmdbId={id}
@@ -197,6 +306,20 @@ const TitleCard = ({
onUpdating={requestUpdating}
onCancel={closeModal}
/>
<BlacklistModal
tmdbId={id}
type={
mediaType === 'movie'
? 'movie'
: mediaType === 'collection'
? 'collection'
: 'tv'
}
show={showBlacklistModal}
onCancel={closeBlacklistModal}
onComplete={onClickHideItemBtn}
isUpdating={isUpdating}
/>
<div
className={`relative transform-gpu cursor-default overflow-hidden rounded-xl bg-gray-800 bg-cover outline-none ring-1 transition duration-300 ${
showDetail
@@ -223,6 +346,7 @@ const TitleCard = ({
>
<div className="absolute inset-0 h-full w-full overflow-hidden">
<CachedImage
type="tmdb"
className="absolute inset-0 h-full w-full"
alt=""
src={
@@ -235,7 +359,7 @@ const TitleCard = ({
/>
<div className="absolute left-0 right-0 flex items-center justify-between p-2">
<div
className={`pointer-events-none z-40 rounded-full border bg-opacity-80 shadow-md ${
className={`pointer-events-none z-40 self-start rounded-full border bg-opacity-80 shadow-md ${
mediaType === 'movie' || mediaType === 'collection'
? 'border-blue-500 bg-blue-600'
: 'border-purple-600 bg-purple-600'
@@ -249,8 +373,8 @@ const TitleCard = ({
: intl.formatMessage(globalMessages.tvshow)}
</div>
</div>
{showDetail && (
<>
{showDetail && currentStatus !== MediaStatus.BLACKLISTED && (
<div className="flex flex-col gap-1">
{toggleWatchlist ? (
<Button
buttonType={'ghost'}
@@ -269,15 +393,49 @@ const TitleCard = ({
<MinusCircleIcon className={'h-3'} />
</Button>
)}
</>
{showHideButton &&
currentStatus !== MediaStatus.PROCESSING &&
currentStatus !== MediaStatus.AVAILABLE &&
currentStatus !== MediaStatus.PARTIALLY_AVAILABLE &&
currentStatus !== MediaStatus.PENDING && (
<Button
buttonType={'ghost'}
className="z-40"
buttonSize={'sm'}
onClick={() => setShowBlacklistModal(true)}
>
<EyeSlashIcon className={'h-3'} />
</Button>
)}
</div>
)}
{showDetail &&
showHideButton &&
currentStatus == MediaStatus.BLACKLISTED && (
<Tooltip
content={intl.formatMessage(
globalMessages.removefromBlacklist
)}
>
<Button
buttonType={'ghost'}
className="z-40"
buttonSize={'sm'}
onClick={() => onClickShowBlacklistBtn()}
>
<EyeIcon className={'h-3'} />
</Button>
</Tooltip>
)}
{currentStatus && currentStatus !== MediaStatus.UNKNOWN && (
<div className="pointer-events-none z-40 flex items-center">
<StatusBadgeMini
status={currentStatus}
inProgress={inProgress}
shrink
/>
<div className="flex flex-col items-center gap-1">
<div className="pointer-events-none z-40 flex">
<StatusBadgeMini
status={currentStatus}
inProgress={inProgress}
shrink
/>
</div>
</div>
)}
</div>

View File

@@ -4,6 +4,7 @@ import RTFresh from '@app/assets/rt_fresh.svg';
import RTRotten from '@app/assets/rt_rotten.svg';
import Spinner from '@app/assets/spinner.svg';
import TmdbLogo from '@app/assets/tmdb_logo.svg';
import BlacklistModal from '@app/components/BlacklistModal';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
@@ -38,6 +39,7 @@ import {
ArrowRightCircleIcon,
CogIcon,
ExclamationTriangleIcon,
EyeSlashIcon,
FilmIcon,
PlayIcon,
} from '@heroicons/react/24/outline';
@@ -59,10 +61,9 @@ import type { Crew } from '@server/models/common';
import type { TvDetails as TvDetailsType } from '@server/models/Tv';
import { countries } from 'country-flag-icons';
import 'country-flag-icons/3x2/flags.css';
import getConfig from 'next/config';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
@@ -126,7 +127,9 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
!tv?.onUserWatchlist
);
const { publicRuntimeConfig } = getConfig();
const [isBlacklistUpdating, setIsBlacklistUpdating] =
useState<boolean>(false);
const [showBlacklistModal, setShowBlacklistModal] = useState(false);
const { addToast } = useToasts();
const {
@@ -157,6 +160,11 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
setShowManager(router.query.manage == '1' ? true : false);
}, [router.query.manage]);
const closeBlacklistModal = useCallback(
() => setShowBlacklistModal(false),
[]
);
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
mediaUrl: data?.mediaInfo?.mediaUrl,
mediaUrl4k: data?.mediaInfo?.mediaUrl4k,
@@ -300,7 +308,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
?.flatrate ?? [];
function getAvalaibleMediaServerName() {
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
}
@@ -312,15 +320,15 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
}
function getAvalaible4kMediaServerName() {
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
return intl.formatMessage(messages.play4k, { mediaServerName: 'Emby' });
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
}
if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) {
return intl.formatMessage(messages.play4k, { mediaServerName: 'Plex' });
}
return intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' });
return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' });
}
const onClickWatchlistBtn = async (): Promise<void> => {
@@ -399,6 +407,60 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
}
};
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: tv?.id,
mediaType: 'tv',
title: tv?.name,
user: user?.id,
}),
});
if (res.status === 201) {
addToast(
<span>
{intl.formatMessage(globalMessages.blacklistSuccess, {
title: tv?.name,
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: tv?.name,
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 (
<div
className="media-page"
@@ -409,6 +471,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
{data.backdropPath && (
<div className="media-page-bg-image">
<CachedImage
type="tmdb"
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -425,6 +488,14 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
</div>
)}
<PageTitle title={data.name} />
<BlacklistModal
tmdbId={data.id}
type="tv"
show={showBlacklistModal}
onCancel={closeBlacklistModal}
onComplete={onClickHideItemBtn}
isUpdating={isBlacklistUpdating}
/>
<IssueModal
onCancel={() => setShowIssueModal(false)}
show={showIssueModal}
@@ -457,6 +528,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
<div className="media-header">
<div className="media-poster">
<CachedImage
type="tmdb"
src={
data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
@@ -530,40 +602,61 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
</span>
</div>
<div className="media-actions">
<>
{toggleWatchlist ? (
<Tooltip content={intl.formatMessage(messages.addtowatchlist)}>
{showHideButton &&
data?.mediaInfo?.status !== MediaStatus.PROCESSING &&
data?.mediaInfo?.status !== MediaStatus.AVAILABLE &&
data?.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE &&
data?.mediaInfo?.status !== MediaStatus.PENDING &&
data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && (
<Tooltip
content={intl.formatMessage(globalMessages.addToBlacklist)}
>
<Button
buttonType={'ghost'}
className="z-40 mr-2"
buttonSize={'md'}
onClick={onClickWatchlistBtn}
onClick={() => setShowBlacklistModal(true)}
>
{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'} />
)}
<EyeSlashIcon className={'h-3'} />
</Button>
</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} />
<RequestButton
mediaType="tv"

View File

@@ -1,11 +1,11 @@
import Alert from '@app/components/Common/Alert';
import CachedImage from '@app/components/Common/CachedImage';
import Modal from '@app/components/Common/Modal';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { MediaServerType } from '@server/constants/server';
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import getConfig from 'next/config';
import Image from 'next/image';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
@@ -36,7 +36,6 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
}) => {
const intl = useIntl();
const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
const { addToast } = useToasts();
const [isImporting, setImporting] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
@@ -81,7 +80,9 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
userCount: createdUsers.length,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
settings.currentSettings.mediaServerType === MediaServerType.EMBY
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
@@ -96,7 +97,9 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
addToast(
intl.formatMessage(messages.importfromJellyfinerror, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
settings.currentSettings.mediaServerType === MediaServerType.EMBY
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
@@ -134,7 +137,9 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
loading={!data && !error}
title={intl.formatMessage(messages.importfromJellyfin, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
settings.currentSettings.mediaServerType === MediaServerType.EMBY
? 'Emby'
: 'Jellyfin',
})}
onOk={() => {
importUsers();
@@ -151,7 +156,8 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
<Alert
title={intl.formatMessage(messages.newJellyfinsigninenabled, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? 'Emby'
: 'Jellyfin',
strong: (msg: React.ReactNode) => (
@@ -243,7 +249,8 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
</td>
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
<div className="flex items-center">
<Image
<CachedImage
type="avatar"
className="h-10 w-10 flex-shrink-0 rounded-full"
src={user.thumb}
alt=""
@@ -277,7 +284,9 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
<Alert
title={intl.formatMessage(messages.noJellyfinuserstoimport, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
settings.currentSettings.mediaServerType === MediaServerType.EMBY
? 'Emby'
: 'Jellyfin',
})}
type="info"
/>

View File

@@ -1,6 +1,7 @@
import Alert from '@app/components/Common/Alert';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import Header from '@app/components/Common/Header';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
@@ -28,8 +29,6 @@ import { MediaServerType } from '@server/constants/server';
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import { hasPermission } from '@server/lib/permissions';
import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
@@ -90,7 +89,6 @@ const UserList = () => {
const intl = useIntl();
const router = useRouter();
const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
const { addToast } = useToasts();
const { user: currentUser, hasPermission: currentHasPermission } = useUser();
const [currentSort, setCurrentSort] = useState<Sort>('displayname');
@@ -535,7 +533,8 @@ const UserList = () => {
>
<InboxArrowDownIcon />
<span>
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
{settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? intl.formatMessage(messages.importfrommediaserver, {
mediaServerName: 'Emby',
})
@@ -634,7 +633,8 @@ const UserList = () => {
href={`/users/${user.id}`}
className="h-10 w-10 flex-shrink-0"
>
<Image
<CachedImage
type="avatar"
className="h-10 w-10 rounded-full object-cover"
src={user.avatar}
alt=""
@@ -690,7 +690,7 @@ const UserList = () => {
<Badge badgeType="default">
{intl.formatMessage(messages.localuser)}
</Badge>
) : publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? (
) : user.userType === UserType.EMBY ? (
<Badge badgeType="success">
{intl.formatMessage(messages.mediaServerUser, {
mediaServerName: 'Emby',

View File

@@ -1,9 +1,9 @@
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import type { User } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
import Image from 'next/image';
import Link from 'next/link';
import { useIntl } from 'react-intl';
@@ -42,7 +42,8 @@ const ProfileHeader = ({ user, isSettingsPage }: ProfileHeaderProps) => {
<div className="flex items-end justify-items-end space-x-5">
<div className="flex-shrink-0">
<div className="relative">
<Image
<CachedImage
type="avatar"
className="h-24 w-24 rounded-full bg-gray-600 object-cover ring-1 ring-gray-700"
src={user.avatar}
alt=""

View File

@@ -14,9 +14,9 @@ import globalMessages from '@app/i18n/globalMessages';
import ErrorPage from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import { ApiErrorCode } from '@server/constants/error';
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
@@ -43,6 +43,7 @@ const messages = defineMessages(
user: 'User',
toastSettingsSuccess: 'Settings saved successfully!',
toastSettingsFailure: 'Something went wrong while saving settings.',
toastSettingsFailureEmail: 'This email is already taken!',
region: 'Discover Region',
regionTip: 'Filter content by regional availability',
originallanguage: 'Discover Language',
@@ -69,7 +70,6 @@ const messages = defineMessages(
const UserGeneralSettings = () => {
const intl = useIntl();
const { publicRuntimeConfig } = getConfig();
const { addToast } = useToasts();
const { locale, setLocale } = useLocale();
const [movieQuotaEnabled, setMovieQuotaEnabled] = useState(false);
@@ -180,7 +180,7 @@ const UserGeneralSettings = () => {
watchlistSyncTv: values.watchlistSyncTv,
}),
});
if (!res.ok) throw new Error();
if (!res.ok) throw new Error(res.statusText, { cause: res });
if (currentUser?.id === user?.id && setLocale) {
setLocale(
@@ -195,10 +195,24 @@ const UserGeneralSettings = () => {
appearance: 'success',
});
} catch (e) {
addToast(intl.formatMessage(messages.toastSettingsFailure), {
autoDismiss: true,
appearance: 'error',
});
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
if (errorData?.message === ApiErrorCode.InvalidEmail) {
addToast(intl.formatMessage(messages.toastSettingsFailureEmail), {
autoDismiss: true,
appearance: 'error',
});
} else {
addToast(intl.formatMessage(messages.toastSettingsFailure), {
autoDismiss: true,
appearance: 'error',
});
}
} finally {
revalidate();
revalidateUser();
@@ -229,7 +243,7 @@ const UserGeneralSettings = () => {
<Badge badgeType="default">
{intl.formatMessage(messages.localuser)}
</Badge>
) : publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? (
) : user?.userType === UserType.EMBY ? (
<Badge badgeType="success">
{intl.formatMessage(messages.mediaServerUser, {
mediaServerName: 'Emby',

View File

@@ -55,6 +55,16 @@ const globalMessages = defineMessages('i18n', {
noresults: 'No results.',
open: 'Open',
resolved: 'Resolved',
blacklist: 'Blacklist',
blacklisted: 'Blacklisted',
blacklistSuccess: '<strong>{title}</strong> was successfully blacklisted.',
blacklistError: 'Something went wrong try again.',
blacklistDuplicateError:
'<strong>{title}</strong> has already been blacklisted.',
removeFromBlacklistSuccess:
'<strong>{title}</strong> was successfully removed from the Blacklist.',
addToBlacklist: 'Add to Blacklist',
removefromBlacklist: 'Remove from Blacklist',
});
export default globalMessages;

View File

@@ -1,7 +1,18 @@
{
"component.BlacklistBlock.blacklistdate": "Blacklisted date",
"component.BlacklistBlock.blacklistedby": "Blacklisted By",
"component.BlacklistModal.blacklisting": "Blacklisting",
"components.AirDateBadge.airedrelative": "Aired {relativeTime}",
"components.AirDateBadge.airsrelative": "Airing {relativeTime}",
"components.AppDataWarning.dockerVolumeMissingDescription": "The <code>{appDataPath}</code> volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.",
"components.Blacklist.blacklistNotFoundError": "<strong>{title}</strong> is not blacklisted.",
"components.Blacklist.blacklistSettingsDescription": "Manage blacklisted media.",
"components.Blacklist.blacklistdate": "date",
"components.Blacklist.blacklistedby": "{date} by {user}",
"components.Blacklist.blacklistsettings": "Blacklist Settings",
"components.Blacklist.mediaName": "Name",
"components.Blacklist.mediaTmdbId": "tmdb Id",
"components.Blacklist.mediaType": "Type",
"components.CollectionDetails.numberofmovies": "{count} Movies",
"components.CollectionDetails.overview": "Overview",
"components.CollectionDetails.requestcollection": "Request Collection",
@@ -73,6 +84,7 @@
"components.Discover.FilterSlideover.releaseDate": "Release Date",
"components.Discover.FilterSlideover.runtime": "Runtime",
"components.Discover.FilterSlideover.runtimeText": "{minValue}-{maxValue} minute runtime",
"components.Discover.FilterSlideover.status": "Status",
"components.Discover.FilterSlideover.streamingservices": "Streaming Services",
"components.Discover.FilterSlideover.studio": "Studio",
"components.Discover.FilterSlideover.tmdbuserscore": "TMDB User Score",
@@ -199,6 +211,7 @@
"components.LanguageSelector.originalLanguageDefault": "All Languages",
"components.Layout.LanguagePicker.displaylanguage": "Display Language",
"components.Layout.SearchInput.searchPlaceholder": "Search Movies & TV",
"components.Layout.Sidebar.blacklist": "Blacklist",
"components.Layout.Sidebar.browsemovies": "Movies",
"components.Layout.Sidebar.browsetv": "Series",
"components.Layout.Sidebar.dashboard": "Discover",
@@ -220,6 +233,7 @@
"components.Layout.VersionStatus.streamdevelop": "Jellyseerr Develop",
"components.Layout.VersionStatus.streamstable": "Jellyseerr Stable",
"components.Login.adminerror": "You must use an admin account to sign in.",
"components.Login.back": "Go back",
"components.Login.credentialerror": "The username or password is incorrect.",
"components.Login.description": "Since this is your first time logging into {applicationName}, you are required to add a valid email address.",
"components.Login.email": "Email Address",
@@ -235,6 +249,7 @@
"components.Login.port": "Port",
"components.Login.save": "Add",
"components.Login.saving": "Adding…",
"components.Login.servertype": "Server Type",
"components.Login.signin": "Sign In",
"components.Login.signingin": "Signing In…",
"components.Login.signinheader": "Sign in to continue",
@@ -256,6 +271,7 @@
"components.Login.validationhostformat": "Valid URL required",
"components.Login.validationhostrequired": "{mediaServerName} URL required",
"components.Login.validationpasswordrequired": "You must provide a password",
"components.Login.validationservertyperequired": "Please select a server type",
"components.Login.validationusernamerequired": "Username required",
"components.ManageSlideOver.alltime": "All Time",
"components.ManageSlideOver.downloadstatus": "Downloads",
@@ -282,6 +298,7 @@
"components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {play} other {plays}}",
"components.ManageSlideOver.removearr": "Remove from {arr}",
"components.ManageSlideOver.removearr4k": "Remove from 4K {arr}",
"components.RequestList.RequestItem.removearr": "Remove from {arr}",
"components.ManageSlideOver.tvshow": "series",
"components.MediaSlider.ShowMoreCard.seemore": "See More",
"components.MovieDetails.MovieCast.fullcast": "Full Cast",
@@ -383,8 +400,12 @@
"components.PermissionEdit.autorequestMoviesDescription": "Grant permission to automatically submit requests for non-4K movies via Plex Watchlist.",
"components.PermissionEdit.autorequestSeries": "Auto-Request Series",
"components.PermissionEdit.autorequestSeriesDescription": "Grant permission to automatically submit requests for non-4K series via Plex Watchlist.",
"components.PermissionEdit.blacklistedItems": "Blacklist media.",
"components.PermissionEdit.blacklistedItemsDescription": "Grant permission to blacklist media.",
"components.PermissionEdit.createissues": "Report Issues",
"components.PermissionEdit.createissuesDescription": "Grant permission to report media issues.",
"components.PermissionEdit.manageblacklist": "Manage Blacklist",
"components.PermissionEdit.manageblacklistDescription": "Grant permission to manage blacklisted media.",
"components.PermissionEdit.manageissues": "Manage Issues",
"components.PermissionEdit.manageissuesDescription": "Grant permission to manage media issues.",
"components.PermissionEdit.managerequests": "Manage Requests",
@@ -403,6 +424,8 @@
"components.PermissionEdit.requestTvDescription": "Grant permission to submit requests for non-4K series.",
"components.PermissionEdit.users": "Manage Users",
"components.PermissionEdit.usersDescription": "Grant permission to manage users. Users with this permission cannot modify users with or grant the Admin privilege.",
"components.PermissionEdit.viewblacklistedItems": "View blacklisted media.",
"components.PermissionEdit.viewblacklistedItemsDescription": "Grant permission to view blacklisted media.",
"components.PermissionEdit.viewissues": "View Issues",
"components.PermissionEdit.viewissuesDescription": "Grant permission to view media issues reported by other users.",
"components.PermissionEdit.viewrecent": "View Recently Added",
@@ -555,9 +578,16 @@
"components.ResetPassword.validationpasswordrequired": "You must provide a password",
"components.Search.search": "Search",
"components.Search.searchresults": "Search Results",
"components.Selector.canceled": "Canceled",
"components.Selector.ended": "Ended",
"components.Selector.inProduction": "In Production",
"components.Selector.nooptions": "No results.",
"components.Selector.pilot": "Pilot",
"components.Selector.planned": "Planned",
"components.Selector.returningSeries": "Returning Series",
"components.Selector.searchGenres": "Select genres…",
"components.Selector.searchKeywords": "Search keywords…",
"components.Selector.searchStatus": "Select status...",
"components.Selector.searchStudios": "Search studios…",
"components.Selector.showless": "Show Less",
"components.Selector.showmore": "Show More",
@@ -820,6 +850,7 @@
"components.Settings.SettingsJobsCache.runnow": "Run Now",
"components.Settings.SettingsJobsCache.sonarr-scan": "Sonarr Scan",
"components.Settings.SettingsJobsCache.unknownJob": "Unknown Job",
"components.Settings.SettingsJobsCache.usersavatars": "Users' Avatars",
"components.Settings.SettingsLogs.copiedLogMessage": "Copied log message to clipboard.",
"components.Settings.SettingsLogs.copyToClipboard": "Copy to Clipboard",
"components.Settings.SettingsLogs.extraData": "Additional Data",
@@ -939,6 +970,7 @@
"components.Settings.address": "Address",
"components.Settings.addsonarr": "Add Sonarr Server",
"components.Settings.advancedTooltip": "Incorrectly configuring this setting may result in broken functionality",
"components.Settings.apiKey": "API key",
"components.Settings.cancelscan": "Cancel Scan",
"components.Settings.copied": "Copied API key to clipboard.",
"components.Settings.currentlibrary": "Current Library: {name}",
@@ -999,6 +1031,7 @@
"components.Settings.save": "Save Changes",
"components.Settings.saving": "Saving…",
"components.Settings.scan": "Sync Libraries",
"components.Settings.scanbackground": "Scanning will run in the background. You can continue the setup process in the meantime.",
"components.Settings.scanning": "Syncing…",
"components.Settings.serverLocal": "local",
"components.Settings.serverRemote": "remote",
@@ -1019,6 +1052,7 @@
"components.Settings.tautulliSettings": "Tautulli Settings",
"components.Settings.tautulliSettingsDescription": "Optionally configure the settings for your Tautulli server. Jellyseerr fetches watch history data for your Plex media from Tautulli.",
"components.Settings.timeout": "Timeout",
"components.Settings.tip": "Tip",
"components.Settings.toastPlexConnecting": "Attempting to connect to Plex…",
"components.Settings.toastPlexConnectingFailure": "Failed to connect to Plex.",
"components.Settings.toastPlexConnectingSuccess": "Plex connection established successfully!",
@@ -1039,18 +1073,23 @@
"components.Settings.webAppUrlTip": "Optionally direct users to the web app on your server instead of the \"hosted\" web app",
"components.Settings.webhook": "Webhook",
"components.Settings.webpush": "Web Push",
"components.Setup.back": "Go back",
"components.Setup.configemby": "Configure Emby",
"components.Setup.configjellyfin": "Configure Jellyfin",
"components.Setup.configplex": "Configure Plex",
"components.Setup.configuremediaserver": "Configure Media Server",
"components.Setup.configureservices": "Configure Services",
"components.Setup.continue": "Continue",
"components.Setup.finish": "Finish Setup",
"components.Setup.finishing": "Finishing…",
"components.Setup.scanbackground": "Scanning will run in the background. You can continue the setup process in the meantime.",
"components.Setup.servertype": "Choose Server Type",
"components.Setup.setup": "Setup",
"components.Setup.signin": "Sign In",
"components.Setup.signin": "Sign in to your account",
"components.Setup.signinMessage": "Get started by signing in",
"components.Setup.signinWithJellyfin": "Use your {mediaServerName} account",
"components.Setup.signinWithPlex": "Use your Plex account",
"components.Setup.tip": "Tip",
"components.Setup.signinWithEmby": "Enter your Emby details",
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
"components.Setup.signinWithPlex": "Enter your Plex details",
"components.Setup.subtitle": "Get started by choosing your media server",
"components.Setup.welcome": "Welcome to Jellyseerr",
"components.StatusBadge.managemedia": "Manage {mediaType}",
"components.StatusBadge.openinarr": "Open in {arr}",
@@ -1195,6 +1234,7 @@
"components.UserProfile.UserSettings.UserGeneralSettings.saving": "Saving…",
"components.UserProfile.UserSettings.UserGeneralSettings.seriesrequestlimit": "Series Request Limit",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailure": "Something went wrong while saving settings.",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailureEmail": "This email is already taken!",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!",
"components.UserProfile.UserSettings.UserGeneralSettings.user": "User",
"components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID",
@@ -1274,6 +1314,7 @@
"components.UserProfile.seriesrequest": "Series Requests",
"components.UserProfile.totalrequests": "Total Requests",
"components.UserProfile.unlimited": "Unlimited",
"i18n.addToBlacklist": "Add to Blacklist",
"i18n.advanced": "Advanced",
"i18n.all": "All",
"i18n.approve": "Approve",
@@ -1281,6 +1322,11 @@
"i18n.areyousure": "Are you sure?",
"i18n.available": "Available",
"i18n.back": "Back",
"i18n.blacklist": "Blacklist",
"i18n.blacklistDuplicateError": "<strong>{title}</strong> has already been blacklisted.",
"i18n.blacklistError": "Something went wrong try again.",
"i18n.blacklistSuccess": "<strong>{title}</strong> was successfully blacklisted.",
"i18n.blacklisted": "Blacklisted",
"i18n.cancel": "Cancel",
"i18n.canceling": "Canceling…",
"i18n.close": "Close",
@@ -1306,6 +1352,8 @@
"i18n.pending": "Pending",
"i18n.previous": "Previous",
"i18n.processing": "Processing",
"i18n.removeFromBlacklistSuccess": "<strong>{title}</strong> was successfully removed from the Blacklist.",
"i18n.removefromBlacklist": "Remove from Blacklist",
"i18n.request": "Request",
"i18n.request4k": "Request in 4K",
"i18n.requested": "Requested",

View File

@@ -12,6 +12,7 @@ import { SettingsProvider } from '@app/context/SettingsContext';
import { UserContext } from '@app/context/UserContext';
import type { User } from '@app/hooks/useUser';
import '@app/styles/globals.css';
import '@app/utils/fetchOverride';
import { polyfillIntl } from '@app/utils/polyfillIntl';
import { MediaServerType } from '@server/constants/server';
import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces';

View File

@@ -0,0 +1,13 @@
import Blacklist from '@app/components/Blacklist';
import useRouteGuard from '@app/hooks/useRouteGuard';
import { Permission } from '@server/lib/permissions';
import type { NextPage } from 'next';
const BlacklistPage: NextPage = () => {
useRouteGuard([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
type: 'or',
});
return <Blacklist />;
};
export default BlacklistPage;

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