Compare commits

..

16 Commits

Author SHA1 Message Date
0xsysr3ll
d0999922cb feat(issue): add issue description preview
This PR adds a description preview to the issues list page, allowing users to quickly view issue details without navigating to individual issue pages.

Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-08-31 22:29:59 +02:00
Gauthier
368ecf8771 fix(dnscaching): display stats for DNS caching (#1858)
* fix(dnscaching): display stats for DNS caching

* fix: add missing translation
2025-08-20 17:32:59 +08:00
Georgy
d3fd5028dc feat: add IMDb rating votes count in tooltip (#1696)
* feat: add IMDb rating votes count in tooltip

* feat: add IMDb rating votes count in tooltip

* feat: add IMDb rating votes count in tooltip
2025-08-19 22:26:42 +02:00
Gauvain
c0fd81a5f0 fix: remove console warning (#1836)
* fix: remove a console warning

* fix: Adds id attribute to select element for accessibility

Fixes label association by adding missing id attribute to the select dropdown, ensuring proper accessibility compliance and screen reader functionality.
2025-08-19 21:49:06 +02:00
0xsysr3ll
af7ceaf7a2 feat(requests): add user's avatar next to Requested/Last Modified by icon (#1750)
* feat(requests): add user's avatar in front of Requested/Last Modified by

* refactor(requests): wrap both the avatar and the username in Link

* fix(requests): remove unnecessary margin between avatar and username
2025-08-20 03:43:51 +08:00
fallenbagel
b4adfd2ffa feat: dns caching manager (#1294)
* feat(dns): implement dns caching

* feat: simple implementation of dnscaching

* feat: dynamic ttl which is revalidated while using stale dns cache

This is done as tmdb ttl is very less like 40 seconds so to make sure
any issues wont be caused due to cached dns (previously we were caching
for 5 minutes no matter what ttl)

* feat(dns): improve DNS cache with multi-strategy fallback system

- multiple DNS resolution strategie
- graceful fallbacks between IPv6 and IPv4 addresses
- network error reporting in fetch fix
- compatibility with cypress testing (I HOPE)

* fix: typos

* feat: dns cache stats in jobs & cache page (and cleanup)

* feat(networksettings): cache dns off by default

* feat: make dnsCache optional and enable-able through network settings

* chore(i18n): extract translation keys

* test(cypress): fix cypress testing

* feat(dnscache): dns cache entries are now flushable

* style(cypress): run prettier

* chore(cypresssettings): git ignore cypress json settings

* chore: ignore cypress/config/settings.json

* fix(dnscache): use entry specific hits and misses not global

* refactor: clean up console logs

* fix(dnscache): fix miss counter

* feat(dnscache): global stats

* chore(i18n): extract translation keys

* refactor: use date-fns for formatting age and remove useless code

* refactor: remove cypress testing options in dnsCacheManager

* refactor: remove console logs

* refactor: removed useless condition when its always truthy

* fix: remove FetchAPI-related code

* fix: remove old ipv4first setting

* refactor: use our own dns-caching package instead

* fix: correct dns-caching module configuration

* fix: correct dns-caching module configuration

* fix: remove useless lru-cache dependency

* fix: update dns-caching to v0.2.0

* fix: add env variable for min/max ttl & update dns-caching

* fix: update dns-caching package

* fix: add force min/max TTL in network settings

* docs: add docs for dns caching

---------

Co-authored-by: Gauthier <mail@gauthierth.fr>
2025-08-20 03:02:21 +08:00
0xsysr3ll
5c1583cf56 fix(UserProfile): handle optional chaining for recentlyWatched data (#1852)
This PR fixes a client-side TypeError in the "Recently Watched" section of user profiles. The issue occurred when recentlyWatched was undefined. The fix adds optional chaining (?.) to prevent the app from crashing.

Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-08-19 19:00:09 +02:00
Gauthier
66d4cd63bb docs(notifications): add more documentation for notifications (#1856)
This PR adds more documentation methods for notifications. Most of it is taken from the Overseerr
documentation.
2025-08-20 00:46:04 +08:00
Ludovic Ortega
e8ec3473da chore(helm): bump jellyseerr to 2.7.3 (#1848)
Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2025-08-14 23:24:09 +02:00
0xsysr3ll
17d4f13afe fix(api): update Plex Watchlist URL (#1847) 2025-08-15 03:57:30 +08:00
0xsysr3ll
3292f11308 fix(MediaRequestSubscriber): use event manager to get fresh media state for MEDIA_AVAILABLE notifications (#1825)
* fix(MediaRequestSubscriber): use event manager to get fresh media state for MEDIA_AVAILABLE notifications

* refactor(MediaRequestSubscriber): streamline media availability notifications
2025-08-10 21:33:06 +02:00
0xsysr3ll
c86ee0ddb1 fix(api): make username field nullable in UserSettings API schema (#1835) 2025-08-06 06:22:21 +08:00
0xsysr3ll
e02ee24f70 fix(media): update delete media file logic to include is4k parameter (#1832)
* fix(media): update delete media file logic to include is4k parameter

* fix(media): revert to MANAGE_REQUESTS permission
2025-08-05 11:42:11 +02:00
0xsysr3ll
ca1686425b fix(blacklist): handle invalid keywords gracefully (#1815)
* fix(blacklist): handle invalid keywords gracefully

* fix(blacklist): only remove keywords on 404 errors

* fix(blacklist): remove non-null assertion and add proper type annotation

* refactor(blacklist): return null instead of 404 for missing keywords

* fix(blacklist): add type annotation for validKeywords

* fix(selector): update type annotation for validKeywords
2025-08-01 11:03:22 +02:00
0xsysr3ll
e52c63164f fix(api): add missing user settings' api docs (#1820)
This PR adds new fields to the UserSettings schema, including username, email, discordId, and various quota limits for movies and TV shows.

It also updates API paths to reference the new UserSettings schema.
2025-07-30 23:44:49 +02:00
Gauthier
e98f31e66c fix(proxy): initialize image proxies after the proxy is set up (#1794)
The ImageProxy for TMDB and TheTVDB were initialized before the proxy settings were set up, so they
were ignoring the proxy settings.

fix #1787
2025-07-24 10:33:53 +02:00
58 changed files with 1607 additions and 2555 deletions

View File

@@ -4,6 +4,7 @@ dist/
config/
CHANGELOG.md
pnpm-lock.yaml
cypress/config/settings.cypress.json
# assets
src/assets/

View File

@@ -21,5 +21,11 @@ module.exports = {
rangeEnd: 0, // default: Infinity
},
},
{
files: 'cypress/config/settings.cypress.json',
options: {
rangeEnd: 0,
},
},
],
};

View File

@@ -3,8 +3,8 @@ kubeVersion: ">=1.23.0-0"
name: jellyseerr-chart
description: Jellyseerr helm chart for Kubernetes
type: application
version: 2.6.1
appVersion: "2.7.1"
version: 2.6.2
appVersion: "2.7.3"
maintainers:
- name: Jellyseerr
url: https://github.com/Fallenbagel/jellyseerr

View File

@@ -1,6 +1,6 @@
# jellyseerr-chart
![Version: 2.6.1](https://img.shields.io/badge/Version-2.6.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.7.1](https://img.shields.io/badge/AppVersion-2.7.1-informational?style=flat-square)
![Version: 2.6.2](https://img.shields.io/badge/Version-2.6.2-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.7.3](https://img.shields.io/badge/AppVersion-2.7.3-informational?style=flat-square)
Jellyseerr helm chart for Kubernetes

View File

@@ -6,7 +6,6 @@
"apiKey": "testkey",
"applicationTitle": "Jellyseerr",
"applicationUrl": "",
"csrfProtection": false,
"cacheImages": false,
"defaultPermissions": 32,
"defaultQuotas": {
@@ -180,5 +179,26 @@
"image-cache-cleanup": {
"schedule": "0 0 5 * * *"
}
},
"network": {
"csrfProtection": false,
"trustProxy": false,
"forceIpv4First": false,
"dnsServers": "",
"proxy": {
"enabled": false,
"hostname": "",
"port": 8080,
"useSsl": false,
"user": "",
"password": "",
"bypassFilter": "",
"bypassLocalAddresses": true
},
"dnsCache": {
"enabled": false,
"forceMinTtl": 0,
"forceMaxTtl": -1
}
}
}

View File

@@ -0,0 +1,21 @@
---
title: Gotify
description: Configure Gotify notifications.
sidebar_position: 5
---
# Gotify
## Configuration
### Server URL
Set this to the URL of your Gotify server.
### Application Token
Add an application to your Gotify server, and set this field to the generated application token.
:::info
Please refer to the [Gotify API documentation](https://gotify.net/docs) for more details on configuring these notifications.
:::

View File

@@ -0,0 +1,29 @@
---
title: ntfy.sh
description: Configure ntfy.sh notifications.
sidebar_position: 6
---
# ntfy.sh
## Configuration
### Server Root URL
Set this to the URL of your ntfy.sh server.
### Topic
Set this to the topic you want to send notifications to.
### Username + Password authentication (optional)
Set this to the username and password for your ntfy.sh server.
### Token authentication (optional)
Set this to the token for your ntfy.sh server.
:::info
Please refer to the [ntfy.sh API documentation](https://docs.ntfy.sh/) for more details on configuring these notifications.
:::

View File

@@ -0,0 +1,23 @@
---
title: Pushbullet
description: Configure Pushbullet notifications.
sidebar_position: 7
---
# Pushbullet
:::info
Users can optionally configure personal notifications in their user settings.
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
:::
## Configuration
### Access Token
[Create an access token](https://www.pushbullet.com/#settings) and set it here to grant Jellyseerr access to the Pushbullet API.
### Channel Tag (optional)
Optionally, [create a channel](https://www.pushbullet.com/my-channel) to allow other users to follow the notification feed using the specified channel tag.

View File

@@ -0,0 +1,27 @@
---
title: Pushover
description: Configure Pushover notifications.
sidebar_position: 8
---
# Pushover
:::info
Users can optionally configure personal notifications in their user settings.
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
:::
## Configuration
### Application/API Token
[Register an application](https://pushover.net/apps/build) and enter the API token in this field. (You can use one of the [official icons in our GitHub repository](https://github.com/fallenbagel/jellyseerr/tree/develop/public) when configuring the application.)
For more details on registering applications or the API token, please see the [Pushover API documentation](https://pushover.net/api#registration).
### User Key
Set this to the user key for your Pushover account. Alternatively, you can set this to a group key to deliver notifications to multiple users.
For more details, please see the [Pushover API documentation](https://pushover.net/api#identifiers).

View File

@@ -0,0 +1,17 @@
---
title: Slack
description: Configure Slack notifications.
sidebar_position: 9
---
# Slack
## Configuration
### Webhook URL
Simply [create a webhook](https://my.slack.com/services/new/incoming-webhook/) and enter the URL in this field.
:::info
Please refer to the [Slack API documentation](https://api.slack.com/messaging/webhooks) for more details on configuring these notifications.
:::

View File

@@ -0,0 +1,39 @@
---
title: Telegram
description: Configure Telegram notifications.
sidebar_position: 10
---
# Telegram
:::info
Users can optionally configure personal notifications in their user settings.
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
:::
## Configuration
:::info
In order to configure Telegram notifications, you first need to [create a bot](https://telegram.me/BotFather).
Bots **cannot** initiate conversations with users, so users must have your bot added to a conversation in order to receive notifications.
:::
### Bot Username (optional)
If this value is configured, users will be able to click a link to start a chat with your bot and configure their own personal notifications.
The bot username should end with `_bot`, and the `@` prefix should be omitted.
### Bot Authentication Token
At the end of the bot creation process, [@BotFather](https://telegram.me/botfather) will provide an authentication token.
### Chat ID
To obtain your chat ID, simply create a new group chat, add [@get_id_bot](https://telegram.me/get_id_bot), and issue the `/my_id` command.
### Send Silently (optional)
Optionally, notifications can be sent silently. Silent notifications send messages without notification sounds.

View File

@@ -0,0 +1,138 @@
---
title: Webhook
description: Configure webhook notifications.
sidebar_position: 4
---
# Webhook
The webhook notification agent enables you to send a custom JSON payload to any endpoint for specific notification events.
## Configuration
### Webhook URL
The URL you would like to post notifications to. Your JSON will be sent as the body of the request.
### Authorization Header (optional)
:::info
This is typically not needed. Please refer to your webhook provider's documentation for details.
:::
This value will be sent as an `Authorization` HTTP header.
### JSON Payload
Customize the JSON payload to suit your needs. Jellyseerr provides several [template variables](#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered.
## Template Variables
### General
| Variable | Value |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `{{notification_type}}` | The type of notification (e.g. `MEDIA_PENDING` or `ISSUE_COMMENT`) |
| `{{event}}` | A friendly description of the notification event |
| `{{subject}}` | The notification subject (typically the media title) |
| `{{message}}` | The notification message body (the media overview/synopsis for request notifications; the issue description for issue notificatons) |
| `{{image}}` | The notification image (typically the media poster) |
### Notify User
These variables are for the target recipient of the notification.
| Variable | Value |
| ---------------------------------------- | ------------------------------------------------------------- |
| `{{notifyuser_username}}` | The target notification recipient's username |
| `{{notifyuser_email}}` | The target notification recipient's email address |
| `{{notifyuser_avatar}}` | The target notification recipient's avatar URL |
| `{{notifyuser_settings_discordId}}` | The target notification recipient's Discord ID (if set) |
| `{{notifyuser_settings_telegramChatId}}` | The target notification recipient's Telegram Chat ID (if set) |
:::info
The `notifyuser` variables are not defined for the following request notification types, as they are intended for application administrators rather than end users:
- Request Pending Approval
- Request Automatically Approved
- Request Processing Failed
On the other hand, the `notifyuser` variables _will_ be replaced with the requesting user's information for the below notification types:
- Request Approved
- Request Declined
- Request Available
If you would like to use the requesting user's information in your webhook, please instead include the relevant variables from the [Request](#request) section below.
:::
### Special
The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`).
| Variable | Value |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `{{media}}` | The relevant media object |
| `{{request}}` | The relevant request object |
| `{{issue}}` | The relevant issue object |
| `{{comment}}` | The relevant issue comment object |
| `{{extra}}` | The "extra" array of additional data for certain notifications (e.g., season/episode numbers for series-related notifications) |
#### Media
The `{{media}}` will be `null` if there is no relevant media object for the notification.
These following special variables are only included in media-related notifications, such as requests.
| Variable | Value |
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
| `{{media_type}}` | The media type (`movie` or `tv`) |
| `{{media_tmdbid}}` | The media's TMDB ID |
| `{{media_tvdbid}}` | The media's TheTVDB ID |
| `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
| `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
#### Request
The `{{request}}` will be `null` if there is no relevant media object for the notification.
The following special variables are only included in request-related notifications.
| Variable | Value |
| ----------------------------------------- | ----------------------------------------------- |
| `{{request_id}}` | The request ID |
| `{{requestedBy_username}}` | The requesting user's username |
| `{{requestedBy_email}}` | The requesting user's email address |
| `{{requestedBy_avatar}}` | The requesting user's avatar URL |
| `{{requestedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
| `{{requestedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
#### Issue
The `{{issue}}` will be `null` if there is no relevant media object for the notification.
The following special variables are only included in issue-related notifications.
| Variable | Value |
| ---------------------------------------- | ----------------------------------------------- |
| `{{issue_id}}` | The issue ID |
| `{{reportedBy_username}}` | The requesting user's username |
| `{{reportedBy_email}}` | The requesting user's email address |
| `{{reportedBy_avatar}}` | The requesting user's avatar URL |
| `{{reportedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
| `{{reportedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
#### Comment
The `{{comment}}` will be `null` if there is no relevant media object for the notification.
The following special variables are only included in issue comment-related notifications.
| Variable | Value |
| ----------------------------------------- | ----------------------------------------------- |
| `{{comment_message}}` | The comment message |
| `{{commentedBy_username}}` | The commenting user's username |
| `{{commentedBy_email}}` | The commenting user's email address |
| `{{commentedBy_avatar}}` | The commenting user's avatar URL |
| `{{commentedBy_settings_discordId}}` | The commenting user's Discord ID (if set) |
| `{{commentedBy_settings_telegramChatId}}` | The commenting user's Telegram Chat ID (if set) |

View File

@@ -0,0 +1,16 @@
---
title: DNS Caching
description: Configure DNS caching settings.
sidebar_position: 7
---
# DNS Caching
Jellyseerr uses DNS caching to improve performance and reduce the number of DNS lookups required for external API calls. This can help speed up response times and reduce load on DNS servers, when something like a Pi-hole is used as a DNS resolver.
## Configuration
You can enable the DNS caching settings in the Network tab of the Jellyseerr settings. The default values follow the standard DNS caching behavior.
- **Force Minimum TTL**: Set a minimum time-to-live (TTL) in seconds for DNS cache entries. This ensures that frequently accessed DNS records are cached for a longer period, reducing the need for repeated lookups. Default is 0.
- **Force Maximum TTL**: Set a maximum time-to-live (TTL) in seconds for DNS cache entries. This prevents infrequently accessed DNS records from being cached indefinitely, allowing for more up-to-date information to be retrieved. Default is -1 (unlimited).

View File

@@ -1,6 +1,7 @@
---
title: Jobs & Cache
description: Configure jobs and cache settings.
sidebar_position: 6
---
# Jobs & Cache

View File

@@ -133,18 +133,6 @@ components:
type: number
example: 5
readOnly: true
plexProfileId:
type: string
example: '12345'
readOnly: true
isPlexProfile:
type: boolean
example: true
readOnly: true
mainPlexUserId:
type: number
example: 1
readOnly: true
required:
- id
- email
@@ -153,14 +141,83 @@ components:
UserSettings:
type: object
properties:
username:
type: string
nullable: true
example: 'Mr User'
email:
type: string
example: 'user@example.com'
discordId:
type: string
nullable: true
example: '123456789'
locale:
type: string
nullable: true
example: 'en'
discoverRegion:
type: string
originalLanguage:
type: string
nullable: true
example: 'US'
streamingRegion:
type: string
nullable: true
example: 'US'
originalLanguage:
type: string
nullable: true
example: 'en'
movieQuotaLimit:
type: number
nullable: true
description: 'Maximum number of movie requests allowed'
example: 10
movieQuotaDays:
type: number
nullable: true
description: 'Time period in days for movie quota'
example: 30
tvQuotaLimit:
type: number
nullable: true
description: 'Maximum number of TV requests allowed'
example: 5
tvQuotaDays:
type: number
nullable: true
description: 'Time period in days for TV quota'
example: 14
globalMovieQuotaDays:
type: number
nullable: true
description: 'Global movie quota days setting'
example: 30
globalMovieQuotaLimit:
type: number
nullable: true
description: 'Global movie quota limit setting'
example: 10
globalTvQuotaLimit:
type: number
nullable: true
description: 'Global TV quota limit setting'
example: 5
globalTvQuotaDays:
type: number
nullable: true
description: 'Global TV quota days setting'
example: 14
watchlistSyncMovies:
type: boolean
nullable: true
description: 'Enable watchlist sync for movies'
example: true
watchlistSyncTv:
type: boolean
nullable: true
description: 'Enable watchlist sync for TV'
example: false
MainSettings:
type: object
properties:
@@ -203,30 +260,51 @@ components:
csrfProtection:
type: boolean
example: false
trustProxy:
type: boolean
example: true
PlexProfile:
type: object
properties:
id:
type: string
example: '12345'
title:
type: string
example: 'Family Member'
username:
type: string
example: 'family_member'
thumb:
type: string
example: 'https://plex.tv/users/avatar.jpg'
isMainUser:
forceIpv4First:
type: boolean
example: false
protected:
trustProxy:
type: boolean
example: true
example: false
proxy:
type: object
properties:
enabled:
type: boolean
example: false
hostname:
type: string
example: ''
port:
type: number
example: 8080
useSsl:
type: boolean
example: false
user:
type: string
example: ''
password:
type: string
example: ''
bypassFilter:
type: string
example: ''
bypassLocalAddresses:
type: boolean
example: true
dnsCache:
type: object
properties:
enabled:
type: boolean
example: false
forceMinTtl:
type: number
example: 0
forceMaxTtl:
type: number
example: -1
PlexLibrary:
type: object
properties:
@@ -2931,6 +3009,68 @@ paths:
imageCount:
type: number
example: 123
dnsCache:
type: object
properties:
stats:
type: object
properties:
size:
type: number
example: 1
maxSize:
type: number
example: 500
hits:
type: number
example: 19
misses:
type: number
example: 1
failures:
type: number
example: 0
ipv4Fallbacks:
type: number
example: 0
hitRate:
type: number
example: 0.95
entries:
type: array
additionalProperties:
type: object
properties:
addresses:
type: object
properties:
ipv4:
type: number
example: 1
ipv6:
type: number
example: 1
activeAddress:
type: string
example: 127.0.0.1
family:
type: number
example: 4
age:
type: number
example: 10
ttl:
type: number
example: 10
networkErrors:
type: number
example: 0
hits:
type: number
example: 1
misses:
type: number
example: 1
apiCaches:
type: array
items:
@@ -2970,6 +3110,21 @@ paths:
responses:
'204':
description: 'Flushed cache'
/settings/cache/dns/{dnsEntry}/flush:
post:
summary: Flush a specific DNS cache entry
description: Flushes a specific DNS cache entry
tags:
- settings
parameters:
- in: path
name: dnsEntry
required: true
schema:
type: string
responses:
'204':
description: 'Flushed dns cache'
/settings/logs:
get:
summary: Returns logs
@@ -3691,17 +3846,17 @@ paths:
/auth/plex:
post:
summary: Sign in using a Plex token
description: |
Takes an `authToken` (Plex token) to log the user in. Generates a session cookie for use in further requests.
If the user does not exist, and there are no other users, then a user will be created with full admin privileges.
If a user logs in with access to the main Plex server, they will also have an account created, but without any permissions.
If the Plex account has multiple profiles, the response will include a `status` field with value `REQUIRES_PROFILE`,
along with the available profiles and the main user ID.
description: Takes an `authToken` (Plex token) to log the user in. Generates a session cookie for use in further requests. If the user does not exist, and there are no other users, then a user will be created with full admin privileges. If a user logs in with access to the main Plex server, they will also have an account created, but without any permissions.
security: []
tags:
- auth
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/User'
requestBody:
required: true
content:
@@ -3711,155 +3866,8 @@ paths:
properties:
authToken:
type: string
profileId:
type: string
description: Optional. If provided, will attempt to authenticate as this specific Plex profile.
pin:
type: string
description: Optional 4-digit profile PIN
isSetup:
type: boolean
description: Set to true during initial setup wizard
required:
- authToken
responses:
'200':
description: OK or profile selection required
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/User'
- type: object
properties:
status:
type: string
enum: [REQUIRES_PROFILE]
example: REQUIRES_PROFILE
mainUserId:
type: number
example: 1
profiles:
type: array
items:
$ref: '#/components/schemas/PlexProfile'
'401':
description: Invalid Plex token (or incorrect 4-digit PIN)
'403':
description: Access denied
'409':
description: Conflict. E-mail or username already exists
'500':
description: Unexpected server error
/auth/plex/profile/select:
post:
summary: Select a Plex profile to log in as
description: |
Selects a specific Plex profile to log in as. The profile must be associated with the main user ID provided.
A session cookie will be generated for the selected profile user.
security: []
tags:
- auth
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
profileId:
type: string
description: The ID of the Plex profile to log in as
mainUserId:
type: number
description: The ID of the main Plex user account
pin:
type: string
description: Optional 4 digit profile PIN
authToken:
type: string
description: Optional Plex token (when reselecting without /plex step)
required:
- profileId
- mainUserId
responses:
'200':
description: OK or PIN required
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/User'
- type: object
properties:
status:
type: string
enum: [REQUIRES_PIN]
example: REQUIRES_PIN
profileId:
type: string
example: '3b969e371cc3df20'
profileName:
type: string
example: 'John Doe'
mainUserId:
type: number
example: 1
'400':
description: Missing required parameters
'401':
description: Invalid Plex token (or incorrect 4-digit PIN)
'403':
description: Access denied
'404':
description: Profile not found
'500':
description: Error selecting profile
/auth/plex/profiles/{userId}:
get:
summary: Get Plex profiles for a given Jellyseerr user
description: |
Returns the list of available Plex home profiles and their corresponding user accounts
linked to the specified Jellyseerr user. The user must be a Plex-based account.
security: []
tags:
- auth
parameters:
- in: path
name: userId
required: true
schema:
type: integer
description: The Jellyseerr user ID of the main Plex account
responses:
'200':
description: List of profiles and linked users
content:
application/json:
schema:
type: object
properties:
profiles:
type: array
items:
$ref: '#/components/schemas/PlexProfile'
profileUsers:
type: array
items:
$ref: '#/components/schemas/User'
mainUser:
$ref: '#/components/schemas/User'
'400':
description: Invalid user ID format or unsupported user type
'404':
description: User not found
'500':
description: Failed to fetch profiles
/auth/jellyfin:
post:
summary: Sign in using a Jellyfin username and password
@@ -4649,11 +4657,7 @@ paths:
content:
application/json:
schema:
type: object
properties:
username:
type: string
example: 'Mr User'
$ref: '#/components/schemas/UserSettings'
post:
summary: Update general settings for a user
description: Updates and returns general settings for a specific user. Requires `MANAGE_USERS` permission if editing other users.
@@ -4670,22 +4674,14 @@ paths:
content:
application/json:
schema:
type: object
properties:
username:
type: string
nullable: true
$ref: '#/components/schemas/UserSettings'
responses:
'200':
description: Updated user general settings returned
content:
application/json:
schema:
type: object
properties:
username:
type: string
example: 'Mr User'
$ref: '#/components/schemas/UserSettings'
/user/{userId}/settings/password:
get:
summary: Get password page informatiom
@@ -6779,9 +6775,16 @@ paths:
example: '1'
schema:
type: string
- in: query
name: is4k
description: Whether to remove from 4K service instance (true) or regular service instance (false)
required: false
example: false
schema:
type: boolean
responses:
'204':
description: Succesfully removed media item
description: Successfully removed media item
/media/{mediaId}/{status}:
post:
summary: Update media status
@@ -7448,11 +7451,22 @@ paths:
example: 1
responses:
'200':
description: Keyword returned
description: Keyword returned (null if not found)
content:
application/json:
schema:
nullable: true
$ref: '#/components/schemas/Keyword'
'500':
description: Internal server error
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: 'Unable to retrieve keyword data.'
/watchproviders/regions:
get:
summary: Get watch provider regions

View File

@@ -57,6 +57,7 @@
"cronstrue": "2.23.0",
"date-fns": "2.29.3",
"dayjs": "1.11.7",
"dns-caching": "^0.2.5",
"email-templates": "12.0.1",
"email-validator": "2.0.4",
"express": "4.21.2",

218
pnpm-lock.yaml generated
View File

@@ -83,6 +83,9 @@ importers:
dayjs:
specifier: 1.11.7
version: 1.11.7
dns-caching:
specifier: ^0.2.5
version: 0.2.5
email-templates:
specifier: 12.0.1
version: 12.0.1(@babel/core@7.24.7)(encoding@0.1.13)(handlebars@4.7.8)(mustache@4.2.0)(pug@3.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(underscore@1.13.7)
@@ -688,8 +691,8 @@ packages:
resolution: {integrity: sha512-NlmJJtvcw72yRJRcnCmGvSi+3jDEg8qFu3z0AFoymmzLx5ERVWyzd9kVXr7Th9/8yIJi2Zc6av4Tqz3wFs8QWg==}
engines: {node: '>=6.9.0'}
'@babel/helpers@7.27.6':
resolution: {integrity: sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==}
'@babel/helpers@7.28.2':
resolution: {integrity: sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==}
engines: {node: '>=6.9.0'}
'@babel/highlight@7.24.7':
@@ -1455,8 +1458,8 @@ packages:
resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.27.6':
resolution: {integrity: sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==}
'@babel/runtime@7.28.2':
resolution: {integrity: sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==}
engines: {node: '>=6.9.0'}
'@babel/template@7.24.7':
@@ -1483,8 +1486,8 @@ packages:
resolution: {integrity: sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==}
engines: {node: '>=6.9.0'}
'@babel/types@7.28.1':
resolution: {integrity: sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==}
'@babel/types@7.28.2':
resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
engines: {node: '>=6.9.0'}
'@codedependant/semantic-release-docker@5.1.0':
@@ -1975,8 +1978,8 @@ packages:
resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
'@jridgewell/gen-mapping@0.3.12':
resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/gen-mapping@0.3.5':
resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
@@ -1990,20 +1993,20 @@ packages:
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
engines: {node: '>=6.0.0'}
'@jridgewell/source-map@0.3.10':
resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==}
'@jridgewell/source-map@0.3.11':
resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==}
'@jridgewell/sourcemap-codec@1.4.15':
resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==}
'@jridgewell/sourcemap-codec@1.5.4':
resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@jridgewell/trace-mapping@0.3.29':
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
'@jridgewell/trace-mapping@0.3.30':
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
@@ -3229,8 +3232,8 @@ packages:
'@swc/helpers@0.5.5':
resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
'@swc/types@0.1.23':
resolution: {integrity: sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==}
'@swc/types@0.1.24':
resolution: {integrity: sha512-tjTMh3V4vAORHtdTprLlfoMptu1WfTZG9Rsca6yOKyNYsRr+MUXutKmliB17orgSZk5DpnDxs8GUdd/qwYxOng==}
'@tailwindcss/aspect-ratio@0.4.2':
resolution: {integrity: sha512-8QPrypskfBa7QIMuKHg2TA7BqES6vhBrDLOv8Unb6FcFyd3TjKbc6lcmb9UPQHxfl24sXoJ41ux/H7qQQvfaSQ==}
@@ -3391,8 +3394,8 @@ packages:
'@types/node@17.0.45':
resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==}
'@types/node@18.19.118':
resolution: {integrity: sha512-hIPK0hSrrcaoAu/gJMzN3QClXE4QdCdFvaenJ0JsjIbExP1JFFVH+RHcBt25c9n8bx5dkIfqKE+uw6BmBns7ug==}
'@types/node@18.19.122':
resolution: {integrity: sha512-yzegtT82dwTNEe/9y+CM8cgb42WrUfMMCg2QqSddzO1J6uPmBD7qKCZ7dOHZP2Yrpm/kb0eqdNMn2MUyEiqBmA==}
'@types/node@20.5.1':
resolution: {integrity: sha512-4tT2UrL5LBqDwoed9wZ6N3umC4Yhz3W3FloMmiiG4JwmUJWpie0c7lcnUNd4gtMKuDEO4wRVS8B6Xa0uMRsMKg==}
@@ -4087,11 +4090,6 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
browserslist@4.24.3:
resolution: {integrity: sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
browserslist@4.25.1:
resolution: {integrity: sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -4196,8 +4194,8 @@ packages:
caniuse-lite@1.0.30001700:
resolution: {integrity: sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==}
caniuse-lite@1.0.30001727:
resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==}
caniuse-lite@1.0.30001734:
resolution: {integrity: sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==}
caseless@0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
@@ -4426,8 +4424,8 @@ packages:
resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
engines: {node: '>= 0.6'}
compression@1.8.0:
resolution: {integrity: sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==}
compression@1.8.1:
resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==}
engines: {node: '>= 0.8.0'}
computed-style@0.1.4:
@@ -4547,8 +4545,8 @@ packages:
core-js-compat@3.37.1:
resolution: {integrity: sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==}
core-js-compat@3.44.0:
resolution: {integrity: sha512-JepmAj2zfl6ogy34qfWtcE7nHKAJnKsQFRn++scjVS2bZFllwptzw61BZcZFYBPpUznLfAvh0LGhxKppk04ClA==}
core-js-compat@3.45.0:
resolution: {integrity: sha512-gRoVMBawZg0OnxaVv3zpqLLxaHmsubEGyTnqdpI/CEBvX4JadI1dMSHxagThprYRtSVbuQxvi6iUatdPxohHpA==}
core-util-is@1.0.2:
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
@@ -4866,6 +4864,9 @@ packages:
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
dns-caching@0.2.5:
resolution: {integrity: sha512-1cnB6i/OG4zfYbRnDjWZDT+FGLvOBuJlFIxVZvHBiaa62SCfaGoP4Si50O14HyoHmx1gadeGWigYXdX5LQAFvg==}
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -4937,8 +4938,8 @@ packages:
electron-to-chromium@1.4.810:
resolution: {integrity: sha512-Kaxhu4T7SJGpRQx99tq216gCq2nMxJo+uuT6uzz9l8TVN2stL7M06MIIXAtr9jsrLs2Glflgf2vMQRepxawOdQ==}
electron-to-chromium@1.5.182:
resolution: {integrity: sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==}
electron-to-chromium@1.5.200:
resolution: {integrity: sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==}
email-templates@12.0.1:
resolution: {integrity: sha512-849pjBFVUAWWTa3HqhDjxlXHaSWmxf4CZOlZ9iVkrSAbQ8YCYi+7KiKqt35L6F20WhSViWX7lmMjno6zBv2rNQ==}
@@ -5506,8 +5507,8 @@ packages:
flow-enums-runtime@0.0.6:
resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==}
flow-parser@0.275.0:
resolution: {integrity: sha512-fHNwawoA2LM7FsxhU/1lTRGq9n6/Q8k861eHgN7GKtamYt9Qrxpg/ZSrev8o1WX7fQ2D3Gg3+uvYN15PmsG7Yw==}
flow-parser@0.278.0:
resolution: {integrity: sha512-9oUcYDHf9n+E/t0FXndgBqGbaUsGEcmWqIr1ldqCzTzctsJV5E/bHusOj4ThB72Ss2mqWpLFNz0+o2c1O8J6+A==}
engines: {node: '>=0.4.0'}
fn.name@1.1.0:
@@ -6744,6 +6745,10 @@ packages:
resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==}
engines: {node: 14 || >=16.14}
lru-cache@11.1.0:
resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -7534,6 +7539,10 @@ packages:
resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==}
engines: {node: '>= 0.8'}
on-headers@1.1.0:
resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==}
engines: {node: '>= 0.8'}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -8838,9 +8847,9 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
source-map@0.7.4:
resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==}
engines: {node: '>= 8'}
source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
@@ -9893,8 +9902,8 @@ packages:
engines: {node: '>= 14'}
hasBin: true
yaml@2.8.0:
resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==}
yaml@2.8.1:
resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==}
engines: {node: '>= 14.6'}
hasBin: true
@@ -10038,11 +10047,11 @@ snapshots:
'@babel/generator': 7.28.0
'@babel/helper-compilation-targets': 7.27.2
'@babel/helper-module-transforms': 7.27.3(@babel/core@7.28.0)
'@babel/helpers': 7.27.6
'@babel/helpers': 7.28.2
'@babel/parser': 7.28.0
'@babel/template': 7.27.2
'@babel/traverse': 7.28.0
'@babel/types': 7.28.1
'@babel/types': 7.28.2
convert-source-map: 2.0.0
debug: 4.4.1
gensync: 1.0.0-beta.2
@@ -10061,9 +10070,9 @@ snapshots:
'@babel/generator@7.28.0':
dependencies:
'@babel/parser': 7.28.0
'@babel/types': 7.28.1
'@jridgewell/gen-mapping': 0.3.12
'@jridgewell/trace-mapping': 0.3.29
'@babel/types': 7.28.2
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.30
jsesc: 3.1.0
'@babel/helper-annotate-as-pure@7.24.7':
@@ -10072,7 +10081,7 @@ snapshots:
'@babel/helper-annotate-as-pure@7.27.3':
dependencies:
'@babel/types': 7.28.1
'@babel/types': 7.28.2
'@babel/helper-builder-binary-assignment-operator-visitor@7.24.7':
dependencies:
@@ -10093,7 +10102,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.28.0
'@babel/helper-validator-option': 7.27.1
browserslist: 4.24.3
browserslist: 4.25.1
lru-cache: 5.1.1
semver: 6.3.1
@@ -10199,7 +10208,7 @@ snapshots:
'@babel/helper-member-expression-to-functions@7.27.1':
dependencies:
'@babel/traverse': 7.28.0
'@babel/types': 7.28.1
'@babel/types': 7.28.2
transitivePeerDependencies:
- supports-color
@@ -10213,7 +10222,7 @@ snapshots:
'@babel/helper-module-imports@7.27.1':
dependencies:
'@babel/traverse': 7.28.0
'@babel/types': 7.28.1
'@babel/types': 7.28.2
transitivePeerDependencies:
- supports-color
@@ -10252,7 +10261,7 @@ snapshots:
'@babel/helper-optimise-call-expression@7.27.1':
dependencies:
'@babel/types': 7.28.1
'@babel/types': 7.28.2
'@babel/helper-plugin-utils@7.24.7': {}
@@ -10320,7 +10329,7 @@ snapshots:
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
dependencies:
'@babel/traverse': 7.28.0
'@babel/types': 7.28.1
'@babel/types': 7.28.2
transitivePeerDependencies:
- supports-color
@@ -10357,7 +10366,7 @@ snapshots:
dependencies:
'@babel/template': 7.27.2
'@babel/traverse': 7.28.0
'@babel/types': 7.28.1
'@babel/types': 7.28.2
transitivePeerDependencies:
- supports-color
@@ -10366,10 +10375,10 @@ snapshots:
'@babel/template': 7.24.7
'@babel/types': 7.24.7
'@babel/helpers@7.27.6':
'@babel/helpers@7.28.2':
dependencies:
'@babel/template': 7.27.2
'@babel/types': 7.28.1
'@babel/types': 7.28.2
'@babel/highlight@7.24.7':
dependencies:
@@ -10388,7 +10397,7 @@ snapshots:
'@babel/parser@7.28.0':
dependencies:
'@babel/types': 7.28.1
'@babel/types': 7.28.2
'@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.24.7(@babel/core@7.24.7)':
dependencies:
@@ -11080,7 +11089,7 @@ snapshots:
'@babel/helper-module-imports': 7.27.1
'@babel/helper-plugin-utils': 7.27.1
'@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.24.7)
'@babel/types': 7.28.1
'@babel/types': 7.28.2
transitivePeerDependencies:
- supports-color
@@ -11374,7 +11383,7 @@ snapshots:
dependencies:
regenerator-runtime: 0.14.1
'@babel/runtime@7.27.6': {}
'@babel/runtime@7.28.2': {}
'@babel/template@7.24.7':
dependencies:
@@ -11386,7 +11395,7 @@ snapshots:
dependencies:
'@babel/code-frame': 7.27.1
'@babel/parser': 7.28.0
'@babel/types': 7.28.1
'@babel/types': 7.28.2
'@babel/traverse@7.24.7':
dependencies:
@@ -11410,7 +11419,7 @@ snapshots:
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.28.0
'@babel/template': 7.27.2
'@babel/types': 7.28.1
'@babel/types': 7.28.2
debug: 4.4.1
transitivePeerDependencies:
- supports-color
@@ -11426,7 +11435,7 @@ snapshots:
'@babel/helper-string-parser': 7.25.9
'@babel/helper-validator-identifier': 7.25.9
'@babel/types@7.28.1':
'@babel/types@7.28.2':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
@@ -12091,10 +12100,10 @@ snapshots:
'@types/yargs': 17.0.33
chalk: 4.1.2
'@jridgewell/gen-mapping@0.3.12':
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.4
'@jridgewell/trace-mapping': 0.3.29
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.30
'@jridgewell/gen-mapping@0.3.5':
dependencies:
@@ -12106,24 +12115,24 @@ snapshots:
'@jridgewell/set-array@1.2.1': {}
'@jridgewell/source-map@0.3.10':
'@jridgewell/source-map@0.3.11':
dependencies:
'@jridgewell/gen-mapping': 0.3.12
'@jridgewell/trace-mapping': 0.3.29
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.30
'@jridgewell/sourcemap-codec@1.4.15': {}
'@jridgewell/sourcemap-codec@1.5.4': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.25':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.4.15
'@jridgewell/trace-mapping@0.3.29':
'@jridgewell/trace-mapping@0.3.30':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.4
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping@0.3.9':
dependencies:
@@ -12871,7 +12880,7 @@ snapshots:
semver: 7.7.1
strip-ansi: 5.2.0
wcwidth: 1.0.1
yaml: 2.8.0
yaml: 2.8.1
transitivePeerDependencies:
- encoding
@@ -12916,7 +12925,7 @@ snapshots:
dependencies:
'@react-native-community/cli-debugger-ui': 13.6.8
'@react-native-community/cli-tools': 13.6.8(encoding@0.1.13)
compression: 1.8.0
compression: 1.8.1
connect: 3.7.0
errorhandler: 1.5.1
nocache: 3.0.4
@@ -13392,7 +13401,7 @@ snapshots:
'@react-three/fiber@8.16.8(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.3.3)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)(three@0.165.0)':
dependencies:
'@babel/runtime': 7.27.6
'@babel/runtime': 7.28.2
'@types/react-reconciler': 0.26.7
'@types/webxr': 0.5.22
base64-js: 1.5.1
@@ -13547,7 +13556,7 @@ snapshots:
'@rnx-kit/chromium-edge-launcher@1.0.0':
dependencies:
'@types/node': 18.19.118
'@types/node': 18.19.122
escape-string-regexp: 4.0.0
is-wsl: 2.2.0
lighthouse-logger: 1.4.2
@@ -13810,7 +13819,7 @@ snapshots:
'@swc/core@1.6.5(@swc/helpers@0.5.11)':
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.23
'@swc/types': 0.1.24
optionalDependencies:
'@swc/core-darwin-arm64': 1.6.5
'@swc/core-darwin-x64': 1.6.5
@@ -13835,7 +13844,7 @@ snapshots:
'@swc/counter': 0.1.3
tslib: 2.8.1
'@swc/types@0.1.23':
'@swc/types@0.1.24':
dependencies:
'@swc/counter': 0.1.3
@@ -14013,7 +14022,7 @@ snapshots:
'@types/node@17.0.45': {}
'@types/node@18.19.118':
'@types/node@18.19.122':
dependencies:
undici-types: 5.26.5
@@ -14689,7 +14698,7 @@ snapshots:
dependencies:
'@babel/core': 7.24.7
'@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.24.7)
core-js-compat: 3.44.0
core-js-compat: 3.45.0
transitivePeerDependencies:
- supports-color
@@ -14804,17 +14813,10 @@ snapshots:
node-releases: 2.0.14
update-browserslist-db: 1.0.16(browserslist@4.23.1)
browserslist@4.24.3:
dependencies:
caniuse-lite: 1.0.30001727
electron-to-chromium: 1.5.182
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.24.3)
browserslist@4.25.1:
dependencies:
caniuse-lite: 1.0.30001727
electron-to-chromium: 1.5.182
caniuse-lite: 1.0.30001734
electron-to-chromium: 1.5.200
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.1)
@@ -14945,7 +14947,7 @@ snapshots:
caniuse-lite@1.0.30001700: {}
caniuse-lite@1.0.30001727: {}
caniuse-lite@1.0.30001734: {}
caseless@0.12.0: {}
@@ -15195,13 +15197,13 @@ snapshots:
dependencies:
mime-db: 1.54.0
compression@1.8.0:
compression@1.8.1:
dependencies:
bytes: 3.1.2
compressible: 2.0.18
debug: 2.6.9
negotiator: 0.6.4
on-headers: 1.0.2
on-headers: 1.1.0
safe-buffer: 5.2.1
vary: 1.1.2
transitivePeerDependencies:
@@ -15332,7 +15334,7 @@ snapshots:
dependencies:
browserslist: 4.23.1
core-js-compat@3.44.0:
core-js-compat@3.45.0:
dependencies:
browserslist: 4.25.1
@@ -15698,6 +15700,10 @@ snapshots:
dlv@1.1.3: {}
dns-caching@0.2.5:
dependencies:
lru-cache: 11.1.0
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -15782,7 +15788,7 @@ snapshots:
electron-to-chromium@1.4.810: {}
electron-to-chromium@1.5.182: {}
electron-to-chromium@1.5.200: {}
email-templates@12.0.1(@babel/core@7.24.7)(encoding@0.1.13)(handlebars@4.7.8)(mustache@4.2.0)(pug@3.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(underscore@1.13.7):
dependencies:
@@ -16087,7 +16093,7 @@ snapshots:
debug: 4.4.0(supports-color@5.5.0)
enhanced-resolve: 5.17.0
eslint: 8.35.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.35.0)
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.35.0))(eslint@8.35.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.54.0(eslint@8.35.0)(typescript@4.9.5))(eslint@8.35.0)
fast-glob: 3.3.2
get-tsconfig: 4.7.5
@@ -16109,7 +16115,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.35.0):
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.35.0))(eslint@8.35.0):
dependencies:
debug: 3.2.7(supports-color@8.1.1)
optionalDependencies:
@@ -16702,7 +16708,7 @@ snapshots:
flow-enums-runtime@0.0.6: {}
flow-parser@0.275.0: {}
flow-parser@0.278.0: {}
fn.name@1.1.0: {}
@@ -17092,7 +17098,7 @@ snapshots:
hermes-profile-transformer@0.0.6:
dependencies:
source-map: 0.7.4
source-map: 0.7.6
highlight.js@10.7.3: {}
@@ -17685,7 +17691,7 @@ snapshots:
'@babel/register': 7.27.1(@babel/core@7.28.0)
babel-core: 7.0.0-bridge.0(@babel/core@7.28.0)
chalk: 4.1.2
flow-parser: 0.275.0
flow-parser: 0.278.0
graceful-fs: 4.2.11
micromatch: 4.0.8
neo-async: 2.6.2
@@ -18036,6 +18042,8 @@ snapshots:
lru-cache@10.2.2: {}
lru-cache@11.1.0: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -18307,13 +18315,13 @@ snapshots:
metro-runtime@0.80.12:
dependencies:
'@babel/runtime': 7.27.6
'@babel/runtime': 7.28.2
flow-enums-runtime: 0.0.6
metro-source-map@0.80.12:
dependencies:
'@babel/traverse': 7.28.0
'@babel/types': 7.28.1
'@babel/types': 7.28.2
flow-enums-runtime: 0.0.6
invariant: 2.2.4
metro-symbolicate: 0.80.12
@@ -18352,7 +18360,7 @@ snapshots:
'@babel/core': 7.28.0
'@babel/generator': 7.28.0
'@babel/parser': 7.28.0
'@babel/types': 7.28.1
'@babel/types': 7.28.2
flow-enums-runtime: 0.0.6
metro: 0.80.12
metro-babel-transformer: 0.80.12
@@ -18375,7 +18383,7 @@ snapshots:
'@babel/parser': 7.28.0
'@babel/template': 7.27.2
'@babel/traverse': 7.28.0
'@babel/types': 7.28.1
'@babel/types': 7.28.2
accepts: 1.3.8
chalk: 4.1.2
ci-info: 2.0.0
@@ -19002,6 +19010,8 @@ snapshots:
on-headers@1.0.2: {}
on-headers@1.1.0: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -20534,7 +20544,7 @@ snapshots:
source-map@0.6.1: {}
source-map@0.7.4: {}
source-map@0.7.6: {}
space-separated-tokens@2.0.2: {}
@@ -20871,7 +20881,7 @@ snapshots:
terser@5.43.1:
dependencies:
'@jridgewell/source-map': 0.3.10
'@jridgewell/source-map': 0.3.11
acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21
@@ -21298,12 +21308,6 @@ snapshots:
escalade: 3.1.2
picocolors: 1.0.1
update-browserslist-db@1.1.3(browserslist@4.24.3):
dependencies:
browserslist: 4.24.3
escalade: 3.2.0
picocolors: 1.1.1
update-browserslist-db@1.1.3(browserslist@4.25.1):
dependencies:
browserslist: 4.25.1
@@ -21594,7 +21598,7 @@ snapshots:
yaml@2.4.5: {}
yaml@2.8.0: {}
yaml@2.8.1: {}
yamljs@0.3.0:
dependencies:

View File

@@ -2,10 +2,10 @@ import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { randomUUID } from 'node:crypto';
import xml2js from 'xml2js';
import ExternalAPI from './externalapi';
interface PlexAccountResponse {
user: PlexUser;
}
@@ -31,37 +31,6 @@ interface PlexUser {
};
entitlements: string[];
}
interface PlexHomeUser {
$: {
id: string;
uuid: string;
title: string;
username?: string;
email?: string;
thumb: string;
protected?: string;
hasPassword?: string;
admin?: string;
guest?: string;
restricted?: string;
};
}
interface PlexHomeUsersResponse {
MediaContainer: {
protected?: string;
User?: PlexHomeUser | PlexHomeUser[];
};
}
export interface PlexProfile {
id: string;
title: string;
username?: string;
thumb: string;
isMainUser?: boolean;
protected?: boolean;
}
interface ConnectionResponse {
$: {
@@ -256,156 +225,6 @@ class PlexTvAPI extends ExternalAPI {
}
}
public async getProfiles(): Promise<PlexProfile[]> {
try {
// First get the main user
const mainUser = await this.getUser();
// Initialize with main user profile
const profiles: PlexProfile[] = [
{
id: mainUser.uuid,
title: mainUser.username,
username: mainUser.username,
thumb: mainUser.thumb,
isMainUser: true,
protected: false, // Will be updated if we get XML data
},
];
try {
// Fetch all profiles including PIN protection status
const response = await axios.get(
'https://clients.plex.tv/api/home/users',
{
headers: {
Accept: 'application/json',
'X-Plex-Token': this.authToken,
'X-Plex-Client-Identifier': randomUUID(),
},
}
);
// Parse the XML response
const parsedXML = await xml2js.parseStringPromise(response.data, {
explicitArray: false,
});
const container = (parsedXML as PlexHomeUsersResponse).MediaContainer;
const rawUsers = container?.User;
if (rawUsers) {
// Convert to array if single user
const users: PlexHomeUser[] = Array.isArray(rawUsers)
? rawUsers
: [rawUsers];
// Update main user's protected status
const mainUserInXml = users.find(
(user) => user.$.uuid === mainUser.uuid
);
if (mainUserInXml) {
profiles[0].protected = mainUserInXml.$.protected === '1';
}
// Add managed profiles (non-main profiles)
const managedProfiles = users
.filter((user) => {
// Validate profile data
const { uuid, title, username } = user.$;
const isValid = Boolean(uuid && (title || username));
// Log invalid profiles but don't include them
if (!isValid) {
logger.warn('Skipping invalid Plex profile entry', {
label: 'Plex.tv API',
uuid,
title,
username,
});
}
// Filter out main user and invalid profiles
return isValid && uuid !== mainUser.uuid;
})
.map((user) => ({
id: user.$.uuid,
title: user.$.title ?? 'Unknown',
username: user.$.username || user.$.title || 'Unknown',
thumb: user.$.thumb ?? '',
protected: user.$.protected === '1',
isMainUser: false,
}));
// Add managed profiles to the results
profiles.push(...managedProfiles);
}
logger.debug('Successfully parsed Plex profiles', {
label: 'Plex.tv API',
count: profiles.length,
});
} catch (e) {
// Continue with just the main user profile if we can't get managed profiles
logger.debug('Could not retrieve managed profiles', {
label: 'Plex.tv API',
errorMessage: e.message,
});
}
return profiles;
} catch (e) {
logger.error('Failed to retrieve Plex profiles', {
label: 'Plex.tv API',
errorMessage: e.message,
});
return [];
}
}
public async switchProfile(
profileId: string,
pin?: string
): Promise<boolean> {
const urlPath = `/api/v2/home/users/${profileId}/switch`;
try {
// @codeql-disable-next-line XssThrough -- False positive: baseURL is hardcoded to Plex API
const response = await axios.post(urlPath, pin ? { pin } : {}, {
baseURL: 'https://clients.plex.tv',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Plex-Token': this.authToken,
'X-Plex-Client-Identifier': randomUUID(),
},
});
return response.status >= 200 && response.status < 300;
} catch (e) {
logger.warn('Failed to switch Plex profile', {
label: 'Plex.TV Metadata API',
errorMessage: e.message,
profileId,
});
return false;
}
}
public async validateProfilePin(
profileId: string,
pin: string
): Promise<boolean> {
try {
const success = await this.switchProfile(profileId, pin);
return success;
} catch (e) {
logger.error('Failed to validate Plex profile pin', {
label: 'Plex.tv API',
errorMessage: e.message,
});
return false;
}
}
public async checkUserAccess(userId: number): Promise<boolean> {
const settings = getSettings();
@@ -472,7 +291,7 @@ class PlexTvAPI extends ExternalAPI {
headers: {
'If-None-Match': cachedWatchlist?.etag,
},
baseURL: 'https://metadata.provider.plex.tv',
baseURL: 'https://discover.provider.plex.tv',
validateStatus: (status) => status < 400, // Allow HTTP 304 to return without error
}
);
@@ -496,7 +315,7 @@ class PlexTvAPI extends ExternalAPI {
const detailedResponse = await this.getRolling<MetadataResponse>(
`/library/metadata/${watchlistItem.ratingKey}`,
{
baseURL: 'https://metadata.provider.plex.tv',
baseURL: 'https://discover.provider.plex.tv',
}
);

View File

@@ -145,6 +145,7 @@ export interface IMDBRating {
title: string;
url: string;
criticsScore: number;
criticsScoreCount: number;
}
/**
@@ -187,6 +188,7 @@ class IMDBRadarrProxy extends ExternalAPI {
title: data[0].Title,
url: `https://www.imdb.com/title/${data[0].ImdbId}`,
criticsScore: data[0].MovieRatings.Imdb.Value,
criticsScoreCount: data[0].MovieRatings.Imdb.Count,
};
} catch (e) {
throw new Error(

View File

@@ -1054,7 +1054,7 @@ class TheMovieDb extends ExternalAPI {
keywordId,
}: {
keywordId: number;
}): Promise<TmdbKeyword> {
}): Promise<TmdbKeyword | null> {
try {
const data = await this.get<TmdbKeyword>(
`/keyword/${keywordId}`,
@@ -1064,6 +1064,9 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
if (e.response?.status === 404) {
return null;
}
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
}
}

View File

@@ -9,7 +9,4 @@ export enum ApiErrorCode {
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
Unauthorized = 'UNAUTHORIZED',
Unknown = 'UNKNOWN',
InvalidPin = 'INVALID_PIN',
NewPlexLoginDisabled = 'NEW_PLEX_LOGIN_DISABLED',
ProfileUserExists = 'PROFILE_USER_EXISTS',
}

View File

@@ -91,15 +91,6 @@ export class User {
@Column({ type: 'varchar', nullable: true, select: false })
public plexToken?: string | null;
@Column({ type: 'varchar', nullable: true })
public plexProfileId?: string | null;
@Column({ type: 'boolean', default: false })
public isPlexProfile?: boolean;
@Column({ type: 'integer', nullable: true })
public mainPlexUserId?: number | null;
@Column({ type: 'integer', default: 0 })
public permissions = 0;

View File

@@ -25,6 +25,7 @@ import imageproxy from '@server/routes/imageproxy';
import { appDataPermissions } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion';
import createCustomProxyAgent from '@server/utils/customProxyAgent';
import { initializeDnsCache } from '@server/utils/dnsCache';
import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip';
import axios from 'axios';
@@ -80,6 +81,14 @@ app
axios.defaults.httpsAgent = new https.Agent({ family: 4 });
}
// Add DNS caching
if (settings.network.dnsCache) {
initializeDnsCache({
forceMinTtl: settings.network.dnsCache.forceMinTtl,
forceMaxTtl: settings.network.dnsCache.forceMaxTtl,
});
}
// Register HTTP proxy
if (settings.network.proxy.enabled) {
await createCustomProxyAgent(settings.network.proxy);

View File

@@ -1,3 +1,4 @@
import type { DnsEntries, DnsStats } from 'dns-caching';
import type { PaginatedResponse } from './common';
export type LogMessage = {
@@ -64,6 +65,10 @@ export interface CacheItem {
export interface CacheResponse {
apiCaches: CacheItem[];
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
dnsCache: {
stats: DnsStats | undefined;
entries: DnsEntries | undefined;
};
}
export interface StatusResponse {

View File

@@ -72,6 +72,7 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
const blacklistedTagsArr = blacklistedTags.split(',');
const pageLimit = settings.main.blacklistedTagsLimit;
const invalidKeywords = new Set<string>();
if (blacklistedTags.length === 0) {
return;
@@ -87,6 +88,19 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
// Iterate for each tag
for (const tag of blacklistedTagsArr) {
const keywordDetails = await tmdb.getKeywordDetails({
keywordId: Number(tag),
});
if (keywordDetails === null) {
logger.warn('Skipping invalid keyword in blacklisted tags', {
label: 'Blacklisted Tags Processor',
keywordId: tag,
});
invalidKeywords.add(tag);
continue;
}
let queryMax = pageLimit * SortOptionsIterable.length;
let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag
@@ -102,24 +116,51 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
throw new AbortTransaction();
}
const response = await getDiscover({
page,
sortBy,
keywords: tag,
});
await this.processResults(response, tag, type, em);
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
try {
const response = await getDiscover({
page,
sortBy,
keywords: tag,
});
this.progress++;
if (page === 1 && response.total_pages <= queryMax) {
// We will finish the tag with less queries than expected, move progress accordingly
this.progress += queryMax - response.total_pages;
fixedSortMode = true;
queryMax = response.total_pages;
await this.processResults(response, tag, type, em);
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
this.progress++;
if (page === 1 && response.total_pages <= queryMax) {
// We will finish the tag with less queries than expected, move progress accordingly
this.progress += queryMax - response.total_pages;
fixedSortMode = true;
queryMax = response.total_pages;
}
} catch (error) {
logger.error('Error processing keyword in blacklisted tags', {
label: 'Blacklisted Tags Processor',
keywordId: tag,
errorMessage: error.message,
});
}
}
}
}
if (invalidKeywords.size > 0) {
const currentTags = blacklistedTagsArr.filter(
(tag) => !invalidKeywords.has(tag)
);
const cleanedTags = currentTags.join(',');
if (cleanedTags !== blacklistedTags) {
settings.main.blacklistedTags = cleanedTags;
await settings.save();
logger.info('Cleaned up invalid keywords from settings', {
label: 'Blacklisted Tags Processor',
removedKeywords: Array.from(invalidKeywords),
newBlacklistedTags: cleanedTags,
});
}
}
}
private async processResults(

View File

@@ -100,17 +100,6 @@ interface Quota {
quotaDays?: number;
}
export interface ProxySettings {
enabled: boolean;
hostname: string;
port: number;
useSsl: boolean;
user: string;
password: string;
bypassFilter: string;
bypassLocalAddresses: boolean;
}
export interface MainSettings {
apiKey: string;
applicationTitle: string;
@@ -138,11 +127,29 @@ export interface MainSettings {
youtubeUrl: string;
}
export interface ProxySettings {
enabled: boolean;
hostname: string;
port: number;
useSsl: boolean;
user: string;
password: string;
bypassFilter: string;
bypassLocalAddresses: boolean;
}
export interface DnsCacheSettings {
enabled: boolean;
forceMinTtl?: number;
forceMaxTtl?: number;
}
export interface NetworkSettings {
csrfProtection: boolean;
forceIpv4First: boolean;
trustProxy: boolean;
proxy: ProxySettings;
dnsCache: DnsCacheSettings;
}
interface PublicSettings {
@@ -542,6 +549,11 @@ class Settings {
bypassFilter: '',
bypassLocalAddresses: true,
},
dnsCache: {
enabled: false,
forceMinTtl: 0,
forceMaxTtl: -1,
},
},
};
if (initialSettings) {

View File

@@ -1,21 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPlexProfilesSupport1745265840052 implements MigrationInterface {
name = 'AddPlexProfilesSupport1745265840052';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user" ADD "plexProfileId" character varying`
);
await queryRunner.query(
`ALTER TABLE "user" ADD "isPlexProfile" boolean NOT NULL DEFAULT false`
);
await queryRunner.query(`ALTER TABLE "user" ADD "mainPlexUserId" integer`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "mainPlexUserId"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isPlexProfile"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "plexProfileId"`);
}
}

View File

@@ -1,21 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPlexProfilesSupport1745265825619 implements MigrationInterface {
name = 'AddPlexProfilesSupport1745265825619';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user" ADD "plexProfileId" character varying`
);
await queryRunner.query(
`ALTER TABLE "user" ADD "isPlexProfile" boolean NOT NULL DEFAULT false`
);
await queryRunner.query(`ALTER TABLE "user" ADD "mainPlexUserId" integer`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "mainPlexUserId"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isPlexProfile"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "plexProfileId"`);
}
}

View File

@@ -18,6 +18,7 @@ import axios from 'axios';
import * as EmailValidator from 'email-validator';
import { Router } from 'express';
import net from 'net';
const authRoutes = Router();
authRoutes.get('/me', isAuthenticated(), async (req, res) => {
@@ -48,12 +49,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
authRoutes.post('/plex', async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);
const body = req.body as {
authToken?: string;
profileId?: string;
pin?: string;
isSetup?: boolean;
};
const body = req.body as { authToken?: string };
if (!body.authToken) {
return next({
@@ -69,97 +65,12 @@ authRoutes.post('/plex', async (req, res, next) => {
) {
return res.status(500).json({ error: 'Plex login is disabled' });
}
try {
// First we need to use this auth token to get the user's email from plex.tv
const plextv = new PlexTvAPI(body.authToken);
const account = await plextv.getUser();
const profiles = await plextv.getProfiles();
const mainUserProfile = profiles.find((p) => p.isMainUser);
// Special handling for setup process
if (body.isSetup) {
let user = await userRepository
.createQueryBuilder('user')
.where('user.plexId = :id', { id: account.id })
.orWhere('user.email = :email', {
email: account.email.toLowerCase(),
})
.getOne();
// First user setup - create the admin user
if (!user && !(await userRepository.count())) {
user = new User({
email: account.email,
plexUsername: account.username,
plexId: account.id,
plexToken: account.authToken,
permissions: Permission.ADMIN,
avatar: account.thumb,
userType: UserType.PLEX,
plexProfileId: mainUserProfile?.id || account.id.toString(),
isPlexProfile: false,
});
settings.main.mediaServerType = MediaServerType.PLEX;
await settings.save();
startJobs();
await userRepository.save(user);
} else if (user) {
// Update existing user with latest Plex data
user.plexToken = account.authToken;
user.plexId = account.id;
user.avatar = account.thumb;
user.plexProfileId = mainUserProfile?.id || account.id.toString();
await userRepository.save(user);
}
// Return user directly, bypassing profile selection
if (user && req.session) {
req.session.userId = user.id;
}
return res.status(200).json(user?.filter() ?? {});
}
// Validate PIN for main account
if (!body.profileId && mainUserProfile?.protected && body.pin) {
const isPinValid = await plextv.validateProfilePin(
mainUserProfile.id,
body.pin
);
if (!isPinValid) {
return next({
status: 403,
error: 'INVALID_PIN.',
});
}
}
// Handle direct profile login
if (body.profileId) {
const profileUser = await userRepository.findOne({
where: { plexProfileId: body.profileId },
});
if (profileUser) {
profileUser.plexToken = body.authToken;
await userRepository.save(profileUser);
if (req.session) {
req.session.userId = profileUser.id;
}
return res.status(200).json(profileUser.filter() ?? {});
} else {
return next({
status: 400,
message: 'Invalid profile selection.',
});
}
}
// Standard Plex authentication flow
// Next let's see if the user already exists
let user = await userRepository
.createQueryBuilder('user')
.where('user.plexId = :id', { id: account.id })
@@ -168,40 +79,7 @@ authRoutes.post('/plex', async (req, res, next) => {
})
.getOne();
const safeUsername = (account.username || account.title)
.replace(/\s+/g, '.')
.replace(/[^a-zA-Z0-9._-]/g, '');
const emailPrefix = account.email.split('@')[0];
const domainPart = account.email.includes('@')
? account.email.split('@')[1]
: 'plex.local';
const proposedEmail = `${emailPrefix}+${safeUsername}@${domainPart}`;
const existingProfileUser = await userRepository.findOne({
where: [
{ plexUsername: account.username, isPlexProfile: true },
{ email: proposedEmail, isPlexProfile: true },
],
});
if (!user && existingProfileUser) {
logger.warn(
'Main user login attempted but profile user already exists for this person',
{
label: 'Auth',
plexUsername: account.username,
email: account.email,
profileUserId: existingProfileUser.id,
}
);
return next({
status: 409,
message:
'A profile user already exists for this Plex account. Please contact your administrator to resolve this duplicate.',
error: ApiErrorCode.ProfileUserExists,
});
}
if (!user && !(await userRepository.count())) {
// First user setup through standard auth flow
user = new User({
email: account.email,
plexUsername: account.username,
@@ -210,8 +88,6 @@ authRoutes.post('/plex', async (req, res, next) => {
permissions: Permission.ADMIN,
avatar: account.thumb,
userType: UserType.PLEX,
plexProfileId: account.id.toString(),
isPlexProfile: false,
});
settings.main.mediaServerType = MediaServerType.PLEX;
@@ -259,15 +135,13 @@ authRoutes.post('/plex', async (req, res, next) => {
}
);
}
// Update existing user
user.plexToken = body.authToken;
user.plexId = account.id;
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
user.userType = UserType.PLEX;
user.plexProfileId = account.id.toString();
user.isPlexProfile = false;
await userRepository.save(user);
} else if (!settings.main.newPlexLogin) {
@@ -283,11 +157,19 @@ authRoutes.post('/plex', async (req, res, next) => {
);
return next({
status: 403,
error: ApiErrorCode.NewPlexLoginDisabled,
message: 'Access denied.',
});
} else {
// Create new user
logger.info(
'Sign-in attempt from Plex user with access to the media server; creating new Jellyseerr user',
{
label: 'API',
ip: req.ip,
email: account.email,
plexId: account.id,
plexUsername: account.username,
}
);
user = new User({
email: account.email,
plexUsername: account.username,
@@ -296,15 +178,13 @@ authRoutes.post('/plex', async (req, res, next) => {
permissions: settings.main.defaultPermissions,
avatar: account.thumb,
userType: UserType.PLEX,
plexProfileId: account.id.toString(),
isPlexProfile: false,
});
await userRepository.save(user);
}
} else {
logger.info(
'Sign-in attempt from Plex user with access to the media server; creating new Jellyseerr user',
logger.warn(
'Failed sign-in attempt by Plex user without access to the media server',
{
label: 'API',
ip: req.ip,
@@ -315,62 +195,17 @@ authRoutes.post('/plex', async (req, res, next) => {
);
return next({
status: 403,
error: ApiErrorCode.NewPlexLoginDisabled,
message: 'Access denied.',
});
}
}
const adminUser = await userRepository.findOne({ where: { id: 1 } });
const isMainUser = profiles.some(
(profile) => profile.isMainUser && profile.id === account.id.toString()
);
const isAdmin = user?.id === adminUser?.id;
if (isMainUser || isAdmin) {
// Only update existing profiles for the main user
for (const profile of profiles) {
if (profile.isMainUser) continue;
const existingProfileUser = await userRepository.findOne({
where: { plexProfileId: profile.id },
});
if (existingProfileUser) {
// Only update profiles that don't have their own Plex ID
// or are already marked as profiles
if (
!existingProfileUser.plexId ||
existingProfileUser.plexId === user.plexId ||
existingProfileUser.isPlexProfile
) {
existingProfileUser.plexToken = user.plexToken;
existingProfileUser.avatar = profile.thumb;
existingProfileUser.plexUsername =
profile.username || profile.title;
await userRepository.save(existingProfileUser);
}
}
}
// Set logged in session
if (req.session) {
req.session.userId = user.id;
}
if (isAdmin || isMainUser) {
// Return main user ID and profiles for selection
const mainUserIdToSend =
user?.id && Number(user.id) > 0 ? Number(user.id) : 1;
return res.status(200).json({
status: 'REQUIRES_PROFILE',
mainUserId: mainUserIdToSend,
profiles: profiles,
});
} else {
// For non-main users, just log them in directly
if (req.session) {
req.session.userId = user.id;
}
return res.status(200).json(user?.filter() ?? {});
}
return res.status(200).json(user?.filter() ?? {});
} catch (e) {
logger.error('Something went wrong authenticating with Plex account', {
label: 'API',
@@ -384,364 +219,6 @@ authRoutes.post('/plex', async (req, res, next) => {
}
});
authRoutes.post('/plex/profile/select', async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);
const profileId = req.body.profileId;
const mainUserIdRaw = req.body.mainUserId;
const pin = req.body.pin;
const authToken = req.body.authToken;
if (!profileId) {
return next({
status: 400,
message: 'Profile ID is required.',
});
}
let mainUserId = 1; // Default to admin user
if (mainUserIdRaw) {
try {
mainUserId =
typeof mainUserIdRaw === 'string'
? parseInt(mainUserIdRaw, 10)
: Number(mainUserIdRaw);
if (isNaN(mainUserId) || mainUserId <= 0) {
mainUserId = 1;
}
} catch (e) {
mainUserId = 1;
}
}
try {
const mainUser = await userRepository.findOne({
where: { id: mainUserId },
});
if (!mainUser) {
return next({
status: 404,
message: 'Main user not found.',
});
}
const tokenToUse = authToken || mainUser.plexToken;
if (!tokenToUse) {
return next({
status: 400,
message: 'No valid Plex token available.',
});
}
const plextv = new PlexTvAPI(tokenToUse);
const profiles = await plextv.getProfiles();
const selectedProfile = profiles.find((p) => p.id === profileId);
if (!selectedProfile) {
return next({
status: 404,
message: 'Selected profile not found.',
});
}
if (
profileId === mainUser.plexProfileId ||
selectedProfile.isMainUser === true
) {
// Check if PIN is required and not provided
if (selectedProfile.protected && !pin) {
return res.status(200).json({
status: 'REQUIRES_PIN',
profileId: profileId,
profileName:
selectedProfile.title || selectedProfile.username || 'Main Account',
mainUserId: mainUserId,
});
}
if (selectedProfile.protected && pin) {
const isPinValid = await plextv.validateProfilePin(profileId, pin);
if (!isPinValid) {
return next({
status: 401,
message: 'Invalid PIN.',
error: ApiErrorCode.InvalidPin,
});
}
try {
await plextv.getUser();
} catch (e) {
return next({
status: 401,
message: 'Invalid PIN.',
error: ApiErrorCode.InvalidPin,
});
}
}
if (mainUser.plexProfileId !== profileId && selectedProfile.isMainUser) {
mainUser.plexProfileId = profileId;
await userRepository.save(mainUser);
}
if (req.session) {
req.session.userId = mainUser.id;
}
return res.status(200).json(mainUser.filter() ?? {});
}
if (selectedProfile.protected && !pin) {
return res.status(200).json({
status: 'REQUIRES_PIN',
profileId: profileId,
profileName:
selectedProfile.title || selectedProfile.username || 'Unknown',
mainUserId: mainUserId,
});
}
if (selectedProfile.protected && pin) {
const isPinValid = await plextv.validateProfilePin(profileId, pin);
if (!isPinValid) {
return next({
status: 401,
message: 'Invalid PIN.',
error: ApiErrorCode.InvalidPin,
});
}
}
const userAccount = await plextv.getUser();
const adminUser = await userRepository.findOne({ where: { id: 1 } });
const isMainPlexUser = profiles.some(
(profile) =>
profile.isMainUser && profile.id === userAccount.id.toString()
);
const isAdminUser = mainUser.id === adminUser?.id;
let profileUser = await userRepository.findOne({
where: [
{ plexProfileId: profileId },
{ plexUsername: selectedProfile.username || selectedProfile.title },
],
});
// Profile doesn't exist yet - only allow creation for admin/main Plex user
if (!profileUser) {
// Profile doesn't exist yet
if (!settings.main.newPlexLogin) {
return next({
status: 403,
error: ApiErrorCode.NewPlexLoginDisabled,
message: 'Access denied.',
});
}
// Only allow profile creation for main Plex user or admin user
if (!isMainPlexUser && !isAdminUser) {
return next({
status: 403,
message: 'Only the Plex server owner can create profile users.',
});
}
// Check for existing users that might match this profile
const emailPrefix = mainUser.email.split('@')[0];
const domainPart = mainUser.email.includes('@')
? mainUser.email.split('@')[1]
: 'plex.local';
const safeUsername = (selectedProfile.username || selectedProfile.title)
.replace(/\s+/g, '.')
.replace(/[^a-zA-Z0-9._-]/g, '');
const proposedEmail = `${emailPrefix}+${safeUsername}@${domainPart}`;
// First check for existing user with this email
const existingEmailUser = await userRepository.findOne({
where: { email: proposedEmail },
});
if (existingEmailUser) {
logger.warn('Found existing user with same email as profile', {
label: 'Auth',
email: proposedEmail,
profileId,
existingUserId: existingEmailUser.id,
});
// Use the existing user
profileUser = existingEmailUser;
if (req.session) {
req.session.userId = profileUser.id;
}
return res.status(200).json(profileUser.filter() ?? {});
} else {
// Then check for any other potential matches
const exactProfileUser = await userRepository.findOne({
where: { plexProfileId: profileId },
});
if (exactProfileUser) {
logger.info('Found existing profile user with exact ID match', {
label: 'Auth',
profileId,
userId: exactProfileUser.id,
});
if (req.session) {
req.session.userId = exactProfileUser.id;
}
return res.status(200).json(exactProfileUser.filter() ?? {});
} else {
// Create a new profile user
profileUser = new User({
email: proposedEmail,
plexUsername: selectedProfile.username || selectedProfile.title,
plexId: mainUser.plexId,
plexToken: tokenToUse,
permissions: settings.main.defaultPermissions,
avatar: selectedProfile.thumb,
userType: UserType.PLEX,
plexProfileId: profileId,
isPlexProfile: true,
mainPlexUserId: mainUser.id,
});
logger.info('Creating new profile user', {
label: 'Auth',
profileId,
email: proposedEmail,
});
await userRepository.save(profileUser);
if (req.session) {
req.session.userId = profileUser.id;
}
return res.status(200).json(profileUser.filter() ?? {});
}
}
} else {
// Profile exists - only set mainPlexUserId if it's the main user creating it
if (
profileUser.plexId &&
profileUser.plexId !== mainUser.plexId &&
!profileUser.isPlexProfile
) {
logger.warn('Attempted to use a regular Plex user as a profile', {
label: 'Auth',
profileId,
userId: profileUser.id,
mainUserId: mainUser.id,
});
// Simply use their account without modifying it
if (req.session) {
req.session.userId = profileUser.id;
}
return res.status(200).json(profileUser.filter() ?? {});
}
// Otherwise update and use this profile
profileUser.plexToken = tokenToUse;
profileUser.avatar = selectedProfile.thumb;
profileUser.plexUsername =
selectedProfile.username || selectedProfile.title;
profileUser.mainPlexUserId = mainUser.id;
profileUser.isPlexProfile = true;
await userRepository.save(profileUser);
if (req.session) {
req.session.userId = profileUser.id;
}
return res.status(200).json(profileUser.filter() ?? {});
}
} catch (e) {
return next({
status: 500,
message: 'Unable to select profile: ' + e.message,
});
}
});
authRoutes.get('/plex/profiles/:userId', async (req, res, next) => {
const userRepository = getRepository(User);
try {
const userId = parseInt(req.params.userId, 10);
if (isNaN(userId)) {
return next({
status: 400,
message: 'Invalid user ID format.',
});
}
const mainUser = await userRepository.findOne({
where: { id: userId },
});
if (!mainUser) {
return next({
status: 404,
message: 'User not found.',
});
}
if (mainUser.userType !== UserType.PLEX) {
return next({
status: 400,
message: 'Only Plex users have profiles.',
});
}
if (!mainUser.plexToken) {
return next({
status: 400,
message: 'User has no valid Plex token.',
});
}
const plextv = new PlexTvAPI(mainUser.plexToken);
const profiles = await plextv.getProfiles();
const profileUsers = await userRepository.find({
where: {
mainPlexUserId: mainUser.id,
isPlexProfile: true,
},
});
return res.status(200).json({
profiles,
profileUsers,
mainUser: mainUser.filter(),
});
} catch (e) {
logger.error('Failed to fetch Plex profiles', {
label: 'API',
errorMessage: e.message,
ip: req.ip,
});
return next({
status: 500,
message: 'Unable to fetch profiles.',
});
}
});
function getUserAvatarUrl(user: User): string {
return `/avatarproxy/${user.jellyfinUserId}?v=${user.avatarVersion}`;
}

View File

@@ -128,11 +128,15 @@ discoverRoutes.get('/movies', async (req, res, next) => {
if (keywords) {
const splitKeywords = keywords.split(',');
keywordData = await Promise.all(
const keywordResults = await Promise.all(
splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
})
);
keywordData = keywordResults.filter(
(keyword): keyword is TmdbKeyword => keyword !== null
);
}
return res.status(200).json({
@@ -415,11 +419,15 @@ discoverRoutes.get('/tv', async (req, res, next) => {
if (keywords) {
const splitKeywords = keywords.split(',');
keywordData = await Promise.all(
const keywordResults = await Promise.all(
splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
})
);
keywordData = keywordResults.filter(
(keyword): keyword is TmdbKeyword => keyword !== null
);
}
return res.status(200).json({

View File

@@ -4,27 +4,40 @@ import { Router } from 'express';
const router = Router();
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});
const tvdbImageProxy = new ImageProxy('tvdb', 'https://artworks.thetvdb.com', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});
// Delay the initialization of ImageProxy instances until the proxy (if any) is properly configured
let _tmdbImageProxy: ImageProxy;
function initTmdbImageProxy() {
if (!_tmdbImageProxy) {
_tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});
}
return _tmdbImageProxy;
}
let _tvdbImageProxy: ImageProxy;
function initTvdbImageProxy() {
if (!_tvdbImageProxy) {
_tvdbImageProxy = new ImageProxy('tvdb', 'https://artworks.thetvdb.com', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});
}
return _tvdbImageProxy;
}
router.get('/:type/*', async (req, res) => {
const imagePath = req.path.replace(/^\/\w+/, '');
try {
let imageData;
if (req.params.type === 'tmdb') {
imageData = await tmdbImageProxy.getImage(imagePath);
imageData = await initTmdbImageProxy().getImage(imagePath);
} else if (req.params.type === 'tvdb') {
imageData = await tvdbImageProxy.getImage(imagePath);
imageData = await initTvdbImageProxy().getImage(imagePath);
} else {
logger.error('Unsupported image type', {
imagePath,

View File

@@ -54,6 +54,8 @@ issueRoutes.get<Record<string, string>, IssueResultsResponse>(
.leftJoinAndSelect('issue.createdBy', 'createdBy')
.leftJoinAndSelect('issue.media', 'media')
.leftJoinAndSelect('issue.modifiedBy', 'modifiedBy')
.leftJoinAndSelect('issue.comments', 'comments')
.leftJoinAndSelect('comments.user', 'user')
.where('issue.status IN (:...issueStatus)', {
issueStatus: statusFilter,
});

View File

@@ -197,8 +197,10 @@ mediaRoutes.delete(
const media = await mediaRepository.findOneOrFail({
where: { id: Number(req.params.id) },
});
const is4k = media.serviceUrl4k !== undefined;
const is4k = req.query.is4k === 'true';
const isMovie = media.mediaType === MediaType.MOVIE;
let serviceSettings;
if (isMovie) {
serviceSettings = settings.radarr.find(
@@ -225,6 +227,7 @@ mediaRoutes.delete(
);
}
}
if (!serviceSettings) {
logger.warn(
`There is no default ${
@@ -239,6 +242,7 @@ mediaRoutes.delete(
);
return;
}
let service;
if (isMovie) {
service = new RadarrAPI({

View File

@@ -28,7 +28,9 @@ import discoverSettingRoutes from '@server/routes/settings/discover';
import { ApiError } from '@server/types/error';
import { appDataPath } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion';
import { dnsCache } from '@server/utils/dnsCache';
import { getHostname } from '@server/utils/getHostname';
import type { DnsEntries, DnsStats } from 'dns-caching';
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import fs from 'fs';
@@ -471,13 +473,13 @@ settingsRoutes.get(
async (req, res, next) => {
const userRepository = getRepository(User);
const qb = userRepository.createQueryBuilder('user');
try {
const admin = await userRepository.findOneOrFail({
select: { id: true, plexToken: true },
where: { id: 1 },
});
const plexApi = new PlexTvAPI(admin.plexToken ?? '');
const plexUsers = (await plexApi.getUsers()).MediaContainer.User.map(
(user) => user.$
).filter((user) => user.email);
@@ -503,7 +505,7 @@ settingsRoutes.get(
plexUsers.map(async (plexUser) => {
if (
!existingUsers.find(
(user: User) =>
(user) =>
user.plexId === parseInt(plexUser.id) ||
user.email === plexUser.email.toLowerCase()
) &&
@@ -513,36 +515,16 @@ settingsRoutes.get(
}
})
);
const profiles = await plexApi.getProfiles();
const existingProfileUsers = await userRepository.find({
where: {
isPlexProfile: true,
},
});
const unimportedProfiles = profiles.filter(
(profile) =>
!profile.isMainUser &&
!existingProfileUsers.some(
(user: User) => user.plexProfileId === profile.id
)
);
return res.status(200).json({
users: sortBy(unimportedPlexUsers, 'username'),
profiles: unimportedProfiles,
});
return res.status(200).json(sortBy(unimportedPlexUsers, 'username'));
} catch (e) {
logger.error(
'Something went wrong getting unimported Plex users and profiles',
{
label: 'API',
errorMessage: e.message,
}
);
logger.error('Something went wrong getting unimported Plex users', {
label: 'API',
errorMessage: e.message,
});
next({
status: 500,
message: 'Unable to retrieve unimported Plex users and profiles.',
message: 'Unable to retrieve unimported Plex users.',
});
}
}
@@ -775,12 +757,19 @@ settingsRoutes.get('/cache', async (_req, res) => {
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
const avatarImageCache = await ImageProxy.getImageStats('avatar');
const stats: DnsStats | undefined = dnsCache?.getStats();
const entries: DnsEntries | undefined = dnsCache?.getCacheEntries();
return res.status(200).json({
apiCaches,
imageCache: {
tmdb: tmdbImageCache,
avatar: avatarImageCache,
},
dnsCache: {
stats,
entries,
},
});
});
@@ -798,6 +787,20 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
}
);
settingsRoutes.post<{ dnsEntry: string }>(
'/cache/dns/:dnsEntry/flush',
(req, res, next) => {
const dnsEntry = req.params.dnsEntry;
if (dnsCache) {
dnsCache.clear(dnsEntry);
return res.status(204).send();
}
next({ status: 404, message: 'Cache not found.' });
}
);
settingsRoutes.post(
'/initialize',
isAuthenticated(Permission.ADMIN),

View File

@@ -528,80 +528,43 @@ router.post(
try {
const settings = getSettings();
const userRepository = getRepository(User);
const { plexIds, profileIds } = req.body as {
plexIds?: string[];
profileIds?: string[];
};
const skippedItems: {
id: string;
type: 'user' | 'profile';
reason: string;
}[] = [];
const createdUsers: User[] = [];
const body = req.body as { plexIds: string[] } | undefined;
// taken from auth.ts
const mainUser = await userRepository.findOneOrFail({
select: { id: true, plexToken: true, email: true, plexId: true },
select: { id: true, plexToken: true },
where: { id: 1 },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
if (plexIds && plexIds.length > 0) {
const plexUsersResponse = await mainPlexTv.getUsers();
const plexUsersResponse = await mainPlexTv.getUsers();
const createdUsers: User[] = [];
for (const rawUser of plexUsersResponse.MediaContainer.User) {
const account = rawUser.$;
for (const rawUser of plexUsersResponse.MediaContainer.User) {
const account = rawUser.$;
if (account.email) {
const user = await userRepository
.createQueryBuilder('user')
.where('user.plexId = :id', { id: account.id })
.orWhere('user.email = :email', {
email: account.email.toLowerCase(),
})
.getOne();
if (account.email && plexIds.includes(account.id)) {
// Check for duplicate users more thoroughly
const user = await userRepository
.createQueryBuilder('user')
.where('user.plexId = :id', { id: account.id })
.orWhere('user.email = :email', {
email: account.email.toLowerCase(),
})
.orWhere('user.plexUsername = :username', {
username: account.username,
})
.getOne();
if (user) {
// Update the user's avatar with their Plex thumbnail, in case it changed
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
// In case the user was previously a local account
if (user.userType === UserType.LOCAL) {
user.userType = UserType.PLEX;
user.plexId = parseInt(account.id);
}
await userRepository.save(user);
skippedItems.push({
id: account.id,
type: 'user',
reason: 'USER_ALREADY_EXISTS',
});
} else if (await mainPlexTv.checkUserAccess(parseInt(account.id))) {
// Check for profiles with the same username
const existingProfile = await userRepository.findOne({
where: {
plexUsername: account.username,
isPlexProfile: true,
},
});
if (existingProfile) {
skippedItems.push({
id: account.id,
type: 'user',
reason: 'PROFILE_WITH_SAME_NAME_EXISTS',
});
continue;
}
if (user) {
// Update the user's avatar with their Plex thumbnail, in case it changed
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
// In case the user was previously a local account
if (user.userType === UserType.LOCAL) {
user.userType = UserType.PLEX;
user.plexId = parseInt(account.id);
}
await userRepository.save(user);
} else if (!body || body.plexIds.includes(account.id)) {
if (await mainPlexTv.checkUserAccess(parseInt(account.id))) {
const newUser = new User({
plexUsername: account.username,
email: account.email,
@@ -611,7 +574,6 @@ router.post(
avatar: account.thumb,
userType: UserType.PLEX,
});
await userRepository.save(newUser);
createdUsers.push(newUser);
}
@@ -619,89 +581,7 @@ router.post(
}
}
if (profileIds && profileIds.length > 0) {
const profiles = await mainPlexTv.getProfiles();
// Filter out real Plex users (with email/isMainUser) from importable profiles
const importableProfiles = profiles.filter((p: any) => !p.isMainUser);
for (const profileId of profileIds) {
const profileData = importableProfiles.find(
(p: any) => p.id === profileId
);
if (profileData) {
// Check for existing user with same plexProfileId
const existingUser = await userRepository.findOne({
where: { plexProfileId: profileId },
});
const emailPrefix = mainUser.email.split('@')[0];
const domainPart = mainUser.email.includes('@')
? mainUser.email.split('@')[1]
: 'plex.local';
const safeUsername = (profileData.username || profileData.title)
.replace(/\s+/g, '.')
.replace(/[^a-zA-Z0-9._-]/g, '');
const proposedEmail = `${emailPrefix}+${safeUsername}@${domainPart}`;
// Check for main user with same plexUsername or email
const mainUserDuplicate = await userRepository.findOne({
where: [
{
plexUsername: profileData.username || profileData.title,
isPlexProfile: false,
},
{ email: proposedEmail, isPlexProfile: false },
],
});
if (existingUser) {
// Skip this profile and add to skipped list
skippedItems.push({
id: profileId,
type: 'profile',
reason: 'DUPLICATE_USER_EXISTS',
});
continue;
}
if (mainUserDuplicate) {
// Skip this profile and add to skipped list, but ensure main user is imported
skippedItems.push({
id: profileId,
type: 'profile',
reason: 'MAIN_USER_ALREADY_EXISTS',
});
// If main user is not already in createdUsers, add it
if (!createdUsers.find((u) => u.id === mainUserDuplicate.id)) {
createdUsers.push(mainUserDuplicate);
}
continue;
}
const profileUser = new User({
email: proposedEmail,
plexUsername: profileData.username || profileData.title,
plexId: mainUser.plexId,
plexToken: mainUser.plexToken,
permissions: settings.main.defaultPermissions,
avatar: profileData.thumb,
userType: UserType.PLEX,
plexProfileId: profileId,
isPlexProfile: true,
mainPlexUserId: mainUser.id,
});
await userRepository.save(profileUser);
createdUsers.push(profileUser);
}
}
}
return res.status(201).json({
data: User.filterMany(createdUsers),
skipped: skippedItems,
});
return res.status(201).json(User.filterMany(createdUsers));
} catch (e) {
next({ status: 500, message: e.message });
}

View File

@@ -33,52 +33,93 @@ import { EventSubscriber } from 'typeorm';
export class MediaRequestSubscriber
implements EntitySubscriberInterface<MediaRequest>
{
private async notifyAvailableMovie(entity: MediaRequest) {
private async notifyAvailableMovie(
entity: MediaRequest,
event?: UpdateEvent<MediaRequest>
) {
// Get fresh media state using event manager
let latestMedia: Media | null = null;
if (event?.manager) {
latestMedia = await event.manager.findOne(Media, {
where: { id: entity.media.id },
});
}
if (!latestMedia) {
const mediaRepository = getRepository(Media);
latestMedia = await mediaRepository.findOne({
where: { id: entity.media.id },
});
}
// Check availability using fresh media state
if (
entity.media[entity.is4k ? 'status4k' : 'status'] ===
MediaStatus.AVAILABLE
!latestMedia ||
latestMedia[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE
) {
const tmdb = new TheMovieDb();
return;
}
try {
const movie = await tmdb.getMovie({
movieId: entity.media.tmdbId,
});
const tmdb = new TheMovieDb();
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
event: `${entity.is4k ? '4K ' : ''}Movie Request Now Available`,
notifyAdmin: false,
notifySystem: true,
notifyUser: entity.requestedBy,
subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`,
message: truncate(movie.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
media: entity.media,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
request: entity,
});
} catch (e) {
logger.error('Something went wrong sending media notification(s)', {
label: 'Notifications',
errorMessage: e.message,
mediaId: entity.id,
});
}
try {
const movie = await tmdb.getMovie({
movieId: entity.media.tmdbId,
});
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
event: `${entity.is4k ? '4K ' : ''}Movie Request Now Available`,
notifyAdmin: false,
notifySystem: true,
notifyUser: entity.requestedBy,
subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`,
message: truncate(movie.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
media: latestMedia,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
request: entity,
});
} catch (e) {
logger.error('Something went wrong sending media notification(s)', {
label: 'Notifications',
errorMessage: e.message,
mediaId: entity.id,
});
}
}
private async notifyAvailableSeries(entity: MediaRequest) {
// Find all seasons in the related media entity
// and see if they are available, then we can check
// if the request contains the same seasons
private async notifyAvailableSeries(
entity: MediaRequest,
event?: UpdateEvent<MediaRequest>
) {
// Get fresh media state with seasons using event manager
let latestMedia: Media | null = null;
if (event?.manager) {
latestMedia = await event.manager.findOne(Media, {
where: { id: entity.media.id },
relations: { seasons: true },
});
}
if (!latestMedia) {
const mediaRepository = getRepository(Media);
latestMedia = await mediaRepository.findOne({
where: { id: entity.media.id },
relations: { seasons: true },
});
}
if (!latestMedia) {
return;
}
// Check availability using fresh media state
const requestedSeasons =
entity.seasons?.map((entitySeason) => entitySeason.seasonNumber) ?? [];
const availableSeasons = entity.media.seasons.filter(
const availableSeasons = latestMedia.seasons.filter(
(season) =>
season[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE &&
requestedSeasons.includes(season.seasonNumber)
@@ -87,44 +128,46 @@ export class MediaRequestSubscriber
availableSeasons.length > 0 &&
availableSeasons.length === requestedSeasons.length;
if (isMediaAvailable) {
const tmdb = new TheMovieDb();
if (!isMediaAvailable) {
return;
}
try {
const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
const tmdb = new TheMovieDb();
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
event: `${entity.is4k ? '4K ' : ''}Series Request Now Available`,
subject: `${tv.name}${
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
}`,
message: truncate(tv.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
notifyAdmin: false,
notifySystem: true,
notifyUser: entity.requestedBy,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
media: entity.media,
extra: [
{
name: 'Requested Seasons',
value: entity.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
request: entity,
});
} catch (e) {
logger.error('Something went wrong sending media notification(s)', {
label: 'Notifications',
errorMessage: e.message,
mediaId: entity.id,
});
}
try {
const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
event: `${entity.is4k ? '4K ' : ''}Series Request Now Available`,
subject: `${tv.name}${
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
}`,
message: truncate(tv.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
notifyAdmin: false,
notifySystem: true,
notifyUser: entity.requestedBy,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
media: latestMedia,
extra: [
{
name: 'Requested Seasons',
value: entity.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
request: entity,
});
} catch (e) {
logger.error('Something went wrong sending media notification(s)', {
label: 'Notifications',
errorMessage: e.message,
mediaId: entity.id,
});
}
}
@@ -782,10 +825,10 @@ export class MediaRequestSubscriber
if (event.entity.status === MediaRequestStatus.COMPLETED) {
if (event.entity.media.mediaType === MediaType.MOVIE) {
this.notifyAvailableMovie(event.entity as MediaRequest);
this.notifyAvailableMovie(event.entity as MediaRequest, event);
}
if (event.entity.media.mediaType === MediaType.TV) {
this.notifyAvailableSeries(event.entity as MediaRequest);
this.notifyAvailableSeries(event.entity as MediaRequest, event);
}
}
}

26
server/utils/dnsCache.ts Normal file
View File

@@ -0,0 +1,26 @@
import logger from '@server/logger';
import { DnsCacheManager } from 'dns-caching';
export let dnsCache: DnsCacheManager | undefined;
export function initializeDnsCache({
forceMinTtl,
forceMaxTtl,
}: {
forceMinTtl?: number;
forceMaxTtl?: number;
}) {
if (dnsCache) {
logger.warn('DNS Cache is already initialized', { label: 'DNS Cache' });
return;
}
logger.info('Initializing DNS Cache', { label: 'DNS Cache' });
dnsCache = new DnsCacheManager({
logger,
forceMinTtl: typeof forceMinTtl === 'number' ? forceMinTtl * 1000 : 0,
forceMaxTtl: typeof forceMaxTtl === 'number' ? forceMaxTtl * 1000 : -1,
});
dnsCache.initialize();
}

View File

@@ -29,14 +29,10 @@ const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => {
const keywordIds = data.blacklistedTags.slice(1, -1).split(',');
Promise.all(
keywordIds.map(async (keywordId) => {
try {
const { data } = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return data.name;
} catch (err) {
return '';
}
const { data } = await axios.get<Keyword | null>(
`/api/v1/keyword/${keywordId}`
);
return data?.name || `[Invalid: ${keywordId}]`;
})
).then((keywords) => {
setTagNamesBlacklistedFor(keywords.join(', '));

View File

@@ -5,7 +5,10 @@ import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { ArrowDownIcon } from '@heroicons/react/24/solid';
import type { TmdbKeywordSearchResponse } from '@server/api/themoviedb/interfaces';
import type {
TmdbKeyword,
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
import type { Keyword } from '@server/models/common';
import axios from 'axios';
import { useFormikContext } from 'formik';
@@ -124,15 +127,19 @@ const ControlledKeywordSelector = ({
const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => {
const { data } = await axios.get<Keyword>(
const { data } = await axios.get<Keyword | null>(
`/api/v1/keyword/${keywordId}`
);
return data;
})
);
const validKeywords: TmdbKeyword[] = keywords.filter(
(keyword): keyword is TmdbKeyword => keyword !== null
);
onChange(
keywords.map((keyword) => ({
validKeywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))

View File

@@ -84,6 +84,7 @@ const SettingsTabs = ({
Select a Tab
</label>
<select
id="tabs"
onChange={(e) => {
router.push(e.target.value);
}}

View File

@@ -77,16 +77,19 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
const keywords = await Promise.all(
slider.data.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>(
const keyword = await axios.get<Keyword | null>(
`/api/v1/keyword/${keywordId}`
);
return keyword.data;
})
);
const validKeywords: Keyword[] = keywords.filter(
(keyword): keyword is Keyword => keyword !== null
);
setDefaultDataValue(
keywords.map((keyword) => ({
validKeywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))

View File

@@ -1,6 +1,7 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import Tooltip from '@app/components/Common/Tooltip';
import { issueOptions } from '@app/components/IssueModal/constants';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
@@ -26,6 +27,7 @@ const messages = defineMessages('components.IssueList.IssueItem', {
opened: 'Opened',
viewissue: 'View Issue',
unknownissuetype: 'Unknown',
descriptionpreview: 'Issue Description',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
@@ -107,8 +109,15 @@ const IssueItem = ({ issue }: IssueItemProps) => {
}
}
const description = issue.comments?.[0]?.message || '';
const maxDescriptionLength = 120;
const shouldTruncate = description.length > maxDescriptionLength;
const truncatedDescription = shouldTruncate
? description.substring(0, maxDescriptionLength) + '...'
: description;
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">
<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:flex-row">
{title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage
@@ -168,8 +177,38 @@ const IssueItem = ({ issue }: IssueItemProps) => {
>
{isMovie(title) ? title.title : title.name}
</Link>
{description && (
<div className="mt-1 max-w-full">
<div className="overflow-hidden text-sm text-gray-300">
{shouldTruncate ? (
<Tooltip
content={
<div className="max-w-sm p-3">
<div className="mb-1 text-sm font-medium text-gray-200">
Issue Description
</div>
<div className="whitespace-pre-wrap text-sm leading-relaxed text-gray-300">
{description}
</div>
</div>
}
tooltipConfig={{
placement: 'top',
offset: [0, 8],
}}
>
<span className="block cursor-help truncate transition-colors hover:text-gray-200">
{truncatedDescription}
</span>
</Tooltip>
) : (
<span className="block break-words">{description}</span>
)}
</div>
</div>
)}
{problemSeasonEpisodeLine.length > 0 && (
<div className="card-field">
<div className="card-field mt-1">
{problemSeasonEpisodeLine.map((t, k) => (
<span key={k}>{t}</span>
))}

View File

@@ -1,195 +0,0 @@
import Button from '@app/components/Common/Button';
import defineMessages from '@app/utils/defineMessages';
import { LockClosedIcon } from '@heroicons/react/24/solid';
import Image from 'next/image';
import { useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
const messages = defineMessages('components.Login.PlexPinEntry', {
pinRequired: 'PIN Required',
pinDescription: 'Enter the PIN for this profile',
submit: 'Submit',
cancel: 'Cancel',
invalidPin: 'Invalid PIN. Please try again.',
pinCheck: 'Checking PIN...',
accessDenied: 'Access denied.',
});
interface PlexPinEntryProps {
profileId: string;
profileName: string;
profileThumb?: string | null;
isProtected?: boolean;
isMainUser?: boolean;
error?: string | null;
onSubmit: (pin: string) => Promise<void>;
onCancel: () => void;
}
const PlexPinEntry = ({
profileName,
profileThumb,
isProtected,
isMainUser,
error,
onSubmit,
onCancel,
}: PlexPinEntryProps) => {
const intl = useIntl();
const [pin, setPin] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
const handleSubmit = async (pinToSubmit?: string) => {
const pinValue = pinToSubmit || pin;
if (!pinValue || isSubmitting) return;
setIsSubmitting(true);
try {
await onSubmit(pinValue);
setPin('');
} catch (err) {
setPin('');
} finally {
setIsSubmitting(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && pin && !isSubmitting) {
handleSubmit();
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/\D/g, '');
setPin(value);
if (value.length === 4 && !isSubmitting) {
handleSubmit(value);
}
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
e.target.select();
};
// PIN boxes rendering
const pinDigits = pin.split('').slice(0, 4);
const boxes = Array.from({ length: 4 }, (_, i) => (
<div
key={i}
className={`mx-2 flex h-12 w-12 items-center justify-center rounded-lg border-2 font-mono text-2xl transition-all
${
i === pin.length
? 'border-indigo-500 ring-2 ring-indigo-500'
: 'border-white/30'
}
${pinDigits[i] ? 'text-white' : 'text-white/40'}`}
aria-label={pinDigits[i] ? 'Entered' : 'Empty'}
>
{pinDigits[i] ? '•' : ''}
</div>
));
return (
<div className="mx-auto flex w-full max-w-md flex-col items-center rounded-2xl border border-white/20 bg-white/10 p-6 shadow-lg backdrop-blur">
<div className="flex w-full flex-col items-center">
{/* Avatar */}
<div className="relative mx-auto mb-1 flex h-20 w-20 shrink-0 grow-0 items-center justify-center overflow-hidden rounded-full bg-gray-900 shadow ring-2 ring-indigo-400">
{profileThumb ? (
<Image
src={profileThumb}
alt={profileName}
fill
sizes="80px"
className="object-cover"
/>
) : (
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-700 text-3xl font-bold text-white">
{profileName?.[0] || '?'}
</span>
)}
</div>
{/* Icons */}
<div className="mb-1 flex items-center justify-center gap-2">
{isProtected && (
<span className="z-10 rounded-full bg-black/80 p-1.5">
<LockClosedIcon className="h-4 w-4 text-indigo-400" />
</span>
)}
{isMainUser && (
<span className="z-10 rounded-full bg-black/80 p-1.5">
<svg
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4 text-yellow-400"
>
<path d="M2.166 6.5l3.5 7 4.334-7 4.334 7 3.5-7L17.5 17.5h-15z" />
</svg>
</span>
)}
</div>
<p className="mb-3 text-center text-base font-semibold text-white">
{profileName}
</p>
<h2 className="mb-3 text-center text-xl font-bold text-white">
{intl.formatMessage(messages.pinRequired)}
</h2>
<p className="mb-4 text-center text-sm text-gray-200">
{intl.formatMessage(messages.pinDescription)}
</p>
<div className="mb-4 flex flex-row items-center justify-center">
{boxes}
{/* Visually hidden input for keyboard entry */}
<input
ref={inputRef}
type="password"
className="absolute opacity-0"
value={pin}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
maxLength={4}
pattern="[0-9]{4}"
inputMode="numeric"
aria-label="PIN Input"
/>
</div>
{error && (
<div
className="mb-4 text-center font-medium text-red-400"
aria-live="polite"
>
{error}
</div>
)}
<div className="flex w-full justify-between">
<Button
buttonType="default"
onClick={onCancel}
className="mr-2 flex-1"
>
{intl.formatMessage(messages.cancel)}
</Button>
<Button
buttonType="primary"
disabled={!pin || isSubmitting}
onClick={() => handleSubmit()}
className="ml-2 flex-1"
>
{isSubmitting
? intl.formatMessage(messages.pinCheck)
: intl.formatMessage(messages.submit)}
</Button>
</div>
</div>
</div>
);
};
export default PlexPinEntry;

View File

@@ -1,170 +0,0 @@
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import PlexPinEntry from '@app/components/Login/PlexPinEntry';
import defineMessages from '@app/utils/defineMessages';
import { LockClosedIcon } from '@heroicons/react/24/solid';
import type { PlexProfile } from '@server/api/plextv';
import Image from 'next/image';
import { useState } from 'react';
import { useIntl } from 'react-intl';
const messages = defineMessages('components.Login.PlexProfileSelector', {
profile: 'Profile',
selectProfile: 'Select Profile',
selectProfileDescription: 'Select which Plex profile you want to use',
selectProfileError: 'Failed to select profile',
});
interface PlexProfileSelectorProps {
profiles: PlexProfile[];
mainUserId: number;
authToken: string | undefined;
onProfileSelected: (
profileId: string,
pin?: string,
onError?: (msg: string) => void
) => Promise<void>;
}
const PlexProfileSelector = ({
profiles,
onProfileSelected,
}: PlexProfileSelectorProps) => {
const intl = useIntl();
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
null
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showPinEntry, setShowPinEntry] = useState(false);
const [selectedProfile, setSelectedProfile] = useState<PlexProfile | null>(
null
);
const handleProfileClick = (profile: PlexProfile) => {
setSelectedProfileId(profile.id);
setSelectedProfile(profile);
if (profile.protected) {
setShowPinEntry(true);
} else {
setIsSubmitting(true);
try {
onProfileSelected(profile.id);
} catch (err) {
setError(intl.formatMessage(messages.selectProfileError));
} finally {
setIsSubmitting(false);
}
}
};
const handlePinSubmit = async (pin: string) => {
if (!selectedProfileId) return;
await onProfileSelected(selectedProfileId, pin);
};
const handlePinCancel = () => {
setShowPinEntry(false);
setSelectedProfile(null);
setSelectedProfileId(null);
};
if (showPinEntry && selectedProfile && selectedProfileId) {
return (
<PlexPinEntry
profileId={selectedProfileId}
profileName={
selectedProfile.title ||
selectedProfile.username ||
intl.formatMessage(messages.profile)
}
profileThumb={selectedProfile.thumb}
isProtected={selectedProfile.protected}
isMainUser={selectedProfile.isMainUser}
onSubmit={handlePinSubmit}
onCancel={handlePinCancel}
/>
);
}
return (
<div className="w-full">
<h2 className="mb-6 text-center text-xl font-bold text-gray-100">
{intl.formatMessage(messages.selectProfile)}
</h2>
<p className="mb-6 text-center text-sm text-gray-300">
{intl.formatMessage(messages.selectProfileDescription)}
</p>
{error && (
<div className="mb-4 rounded-md bg-red-600 p-3 text-white">
{intl.formatMessage(messages.selectProfileError)}
</div>
)}
<div className="relative mb-6">
{isSubmitting && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-black/50">
<SmallLoadingSpinner />
</div>
)}
<div className="grid grid-cols-2 justify-items-center gap-4 sm:grid-cols-3 sm:gap-6 md:gap-8">
{profiles.map((profile) => (
<button
key={profile.id}
type="button"
onClick={() => handleProfileClick(profile)}
disabled={
isSubmitting ||
(selectedProfileId === profile.id && !profile.protected)
}
className={`relative flex h-48 w-32 flex-col items-center justify-start rounded-2xl border border-white/20 bg-white/10 p-6 shadow-lg backdrop-blur transition-all hover:ring-2 hover:ring-indigo-400 ${
selectedProfileId === profile.id
? 'bg-indigo-600 ring-2 ring-indigo-400'
: 'border border-white/20 bg-white/10 backdrop-blur-sm'
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : ''}`}
>
<div className="relative mx-auto mb-2 flex h-20 w-20 shrink-0 grow-0 items-center justify-center overflow-hidden rounded-full bg-gray-900 shadow ring-2 ring-indigo-400">
<Image
src={profile.thumb}
alt={profile.title || profile.username || 'Profile'}
fill
sizes="80px"
className="object-cover"
/>
</div>
<div className="mb-2 flex items-center justify-center gap-2">
{profile.protected && (
<span className="z-10 rounded-full bg-black/80 p-1.5">
<LockClosedIcon className="h-4 w-4 text-indigo-400" />
</span>
)}
{profile.isMainUser && (
<span className="z-10 rounded-full bg-black/80 p-1.5">
<svg
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4 text-yellow-400"
>
<path d="M2.166 6.5l3.5 7 4.334-7 4.334 7 3.5-7L17.5 17.5h-15z" />
</svg>
</span>
)}
</div>
<span
className="mb-1 w-full break-words text-center text-base font-semibold text-white"
title={profile.username || profile.title}
>
{profile.username || profile.title}
</span>
</button>
))}
</div>
</div>
</div>
);
};
export default PlexProfileSelector;

View File

@@ -8,15 +8,11 @@ import LanguagePicker from '@app/components/Layout/LanguagePicker';
import JellyfinLogin from '@app/components/Login/JellyfinLogin';
import LocalLogin from '@app/components/Login/LocalLogin';
import PlexLoginButton from '@app/components/Login/PlexLoginButton';
import PlexPinEntry from '@app/components/Login/PlexPinEntry';
import PlexProfileSelector from '@app/components/Login/PlexProfileSelector';
import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { XCircleIcon } from '@heroicons/react/24/solid';
import type { PlexProfile } from '@server/api/plextv';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType } from '@server/constants/server';
import axios from 'axios';
import { useRouter } from 'next/dist/client/router';
@@ -33,11 +29,6 @@ const messages = defineMessages('components.Login', {
signinwithjellyfin: 'Use your {mediaServerName} account',
signinwithoverseerr: 'Use your {applicationTitle} account',
orsigninwith: 'Or sign in with',
authFailed: 'Authentication failed',
invalidPin: 'Invalid PIN. Please try again.',
accessDenied: 'Access denied.',
profileUserExists:
'A profile user already exists for this Plex account. Please contact your administrator to resolve this duplicate.',
});
const Login = () => {
@@ -48,158 +39,36 @@ const Login = () => {
const [error, setError] = useState('');
const [isProcessing, setProcessing] = useState(false);
const [authToken, setAuthToken] = useState<string | undefined>();
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
const [mediaServerLogin, setMediaServerLogin] = useState(
settings.currentSettings.mediaServerLogin
);
const profilesRef = useRef<PlexProfile[]>([]);
const [profiles, setProfiles] = useState<PlexProfile[]>([]);
const [mainUserId, setMainUserId] = useState<number | null>(null);
const [showProfileSelector, setShowProfileSelector] = useState(false);
const [showPinEntry, setShowPinEntry] = useState(false);
const [pinProfileId, setPinProfileId] = useState<string | null>(null);
const [pinProfileName, setPinProfileName] = useState<string | null>(null);
const [pinProfileThumb, setPinProfileThumb] = useState<string | null>(null);
const [pinIsProtected, setPinIsProtected] = useState<boolean>(false);
const [pinIsMainUser, setPinIsMainUser] = useState<boolean>(false);
const [pinError, setPinError] = useState<string | null>(null);
// 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
// ask swr to revalidate the user which _should_ come back with a valid user.
useEffect(() => {
const login = async () => {
setProcessing(true);
try {
const response = await axios.post('/api/v1/auth/plex', { authToken });
switch (response.data?.status) {
case 'REQUIRES_PIN': {
setPinProfileId(response.data.profileId);
setPinProfileName(response.data.profileName);
setPinProfileThumb(response.data.profileThumb);
setPinIsProtected(response.data.isProtected);
setPinIsMainUser(response.data.isMainUser);
setShowPinEntry(true);
break;
}
case 'REQUIRES_PROFILE': {
setProfiles(response.data.profiles);
profilesRef.current = response.data.profiles;
const rawUserId = response.data.mainUserId;
let numericUserId = Number(rawUserId);
if (!numericUserId || isNaN(numericUserId) || numericUserId <= 0) {
numericUserId = 1;
}
setMainUserId(numericUserId);
setShowProfileSelector(true);
break;
}
default:
if (response.data?.id) {
revalidate();
}
break;
if (response.data?.id) {
revalidate();
}
} catch (e) {
const httpStatus = e?.response?.status;
const msg =
httpStatus === 403
? intl.formatMessage(messages.accessDenied)
: e?.response?.data?.message ??
intl.formatMessage(messages.authFailed);
setError(msg);
setError(e.response?.data?.message);
setAuthToken(undefined);
} finally {
setProcessing(false);
}
};
if (authToken) {
login();
}
}, [authToken, revalidate, intl]);
const handleSubmitProfile = async (
profileId: string,
pin?: string,
onError?: (msg: string) => void
) => {
setProcessing(true);
setError('');
try {
const payload = {
profileId,
mainUserId,
...(pin && { pin }),
...(authToken && { authToken }),
};
const response = await axios.post(
'/api/v1/auth/plex/profile/select',
payload
);
if (response.data?.status === 'REQUIRES_PIN') {
setShowPinEntry(true);
setPinProfileId(profileId);
setPinProfileName(
profiles.find((p) => p.id === profileId)?.title ||
profiles.find((p) => p.id === profileId)?.username ||
'Profile'
);
setPinProfileThumb(
profiles.find((p) => p.id === profileId)?.thumb || null
);
setPinIsProtected(
profiles.find((p) => p.id === profileId)?.protected || false
);
setPinIsMainUser(
profiles.find((p) => p.id === profileId)?.isMainUser || false
);
setPinError(intl.formatMessage(messages.invalidPin));
throw new Error('Invalid PIN');
} else {
setShowProfileSelector(false);
setShowPinEntry(false);
setPinError(null);
setPinProfileId(null);
setPinProfileName(null);
setPinProfileThumb(null);
setPinIsProtected(false);
setPinIsMainUser(false);
revalidate();
}
} catch (e) {
const code = e?.response?.data?.error as string | undefined;
const httpStatus = e?.response?.status;
let msg: string;
switch (code) {
case ApiErrorCode.NewPlexLoginDisabled:
msg = intl.formatMessage(messages.accessDenied);
break;
case ApiErrorCode.InvalidPin:
msg = intl.formatMessage(messages.invalidPin);
break;
case ApiErrorCode.ProfileUserExists:
msg = intl.formatMessage(messages.profileUserExists);
break;
default:
if (httpStatus === 401) {
msg = intl.formatMessage(messages.invalidPin);
} else if (httpStatus === 403) {
msg = intl.formatMessage(messages.accessDenied);
} else {
msg =
e?.response?.data?.message ??
intl.formatMessage(messages.authFailed);
}
}
setError(msg);
if (onError) {
onError(msg);
}
}
};
}, [authToken, revalidate]);
// Effect that is triggered whenever `useUser`'s user changes. If we get a new
// valid user, we redirect the user to the home page as the login was successful.
useEffect(() => {
if (user) {
router.push('/');
@@ -328,85 +197,48 @@ const Login = () => {
</div>
</Transition>
<div className="px-10 py-8">
{showPinEntry && pinProfileId && pinProfileName ? (
<PlexPinEntry
profileId={pinProfileId}
profileName={pinProfileName}
profileThumb={pinProfileThumb}
isProtected={pinIsProtected}
isMainUser={pinIsMainUser}
error={pinError}
onSubmit={(pin) => {
return handleSubmitProfile(pinProfileId, pin);
<SwitchTransition mode="out-in">
<CSSTransition
key={mediaServerLogin ? 'ms' : 'local'}
nodeRef={loginRef}
addEndListener={(done) => {
loginRef.current?.addEventListener(
'transitionend',
done,
false
);
}}
onCancel={() => {
setShowPinEntry(false);
setPinProfileId(null);
setPinProfileName(null);
setPinProfileThumb(null);
setPinIsProtected(false);
setPinIsMainUser(false);
setPinError(null);
setShowProfileSelector(true);
onEntered={() => {
document
.querySelector<HTMLInputElement>('#email, #username')
?.focus();
}}
/>
) : showProfileSelector ? (
<PlexProfileSelector
profiles={profiles}
mainUserId={mainUserId || 1}
authToken={authToken}
onProfileSelected={(profileId, pin, onError) =>
handleSubmitProfile(profileId, pin, onError)
}
/>
) : (
<SwitchTransition mode="out-in">
<CSSTransition
key={mediaServerLogin ? 'ms' : 'local'}
nodeRef={loginRef}
addEndListener={(done) => {
loginRef.current?.addEventListener(
'transitionend',
done,
false
);
}}
onEntered={() => {
document
.querySelector<HTMLInputElement>('#email, #username')
?.focus();
}}
classNames={{
appear: 'opacity-0',
appearActive:
'transition-opacity duration-500 opacity-100',
enter: 'opacity-0',
enterActive:
'transition-opacity duration-500 opacity-100',
exitActive: 'transition-opacity duration-0 opacity-0',
}}
>
<div ref={loginRef} className="button-container">
{isJellyfin &&
(mediaServerLogin ||
!settings.currentSettings.localLogin) ? (
<JellyfinLogin
serverType={settings.currentSettings.mediaServerType}
revalidate={revalidate}
/>
) : (
settings.currentSettings.localLogin && (
<LocalLogin revalidate={revalidate} />
)
)}
</div>
</CSSTransition>
</SwitchTransition>
)}
classNames={{
appear: 'opacity-0',
appearActive: 'transition-opacity duration-500 opacity-100',
enter: 'opacity-0',
enterActive: 'transition-opacity duration-500 opacity-100',
exitActive: 'transition-opacity duration-0 opacity-0',
}}
>
<div ref={loginRef} className="button-container">
{isJellyfin &&
(mediaServerLogin ||
!settings.currentSettings.localLogin) ? (
<JellyfinLogin
serverType={settings.currentSettings.mediaServerType}
revalidate={revalidate}
/>
) : (
settings.currentSettings.localLogin && (
<LocalLogin revalidate={revalidate} />
)
)}
</div>
</CSSTransition>
</SwitchTransition>
{!showProfileSelector &&
!showPinEntry &&
additionalLoginOptions.length > 0 &&
{additionalLoginOptions.length > 0 &&
(loginFormVisible ? (
<div className="flex items-center py-5">
<div className="flex-grow border-t border-gray-600"></div>
@@ -421,15 +253,13 @@ const Login = () => {
</h2>
))}
{!showProfileSelector && !showPinEntry && (
<div
className={`flex w-full flex-wrap gap-2 ${
!loginFormVisible ? 'flex-col' : ''
}`}
>
{additionalLoginOptions}
</div>
)}
<div
className={`flex w-full flex-wrap gap-2 ${
!loginFormVisible ? 'flex-col' : ''
}`}
>
{additionalLoginOptions}
</div>
</div>
</>
</div>

View File

@@ -118,9 +118,11 @@ const ManageSlideOver = ({
}
};
const deleteMediaFile = async () => {
const deleteMediaFile = async (is4k = false) => {
if (data.mediaInfo) {
await axios.delete(`/api/v1/media/${data.mediaInfo.id}/file`);
await axios.delete(
`/api/v1/media/${data.mediaInfo.id}/file?is4k=${is4k}`
);
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
revalidate();
onClose();
@@ -414,7 +416,7 @@ const ManageSlideOver = ({
isDefaultService() && (
<div>
<ConfirmButton
onClick={() => deleteMediaFile()}
onClick={() => deleteMediaFile(false)}
confirmText={intl.formatMessage(
globalMessages.areyousure
)}
@@ -573,7 +575,7 @@ const ManageSlideOver = ({
{isDefaultService() && (
<div>
<ConfirmButton
onClick={() => deleteMediaFile()}
onClick={() => deleteMediaFile(true)}
confirmText={intl.formatMessage(
globalMessages.areyousure
)}

View File

@@ -99,7 +99,7 @@ const messages = defineMessages('components.MovieDetails', {
rtcriticsscore: 'Rotten Tomatoes Tomatometer',
rtaudiencescore: 'Rotten Tomatoes Audience Score',
tmdbuserscore: 'TMDB User Score',
imdbuserscore: 'IMDB User Score',
imdbuserscore: 'IMDB User Score votes: {formattedCount}',
watchlistSuccess: '<strong>{title}</strong> added to watchlist successfully!',
watchlistDeleted:
'<strong>{title}</strong> Removed from watchlist successfully!',
@@ -812,7 +812,18 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
</Tooltip>
)}
{ratingData?.imdb?.criticsScore && (
<Tooltip content={intl.formatMessage(messages.imdbuserscore)}>
<Tooltip
content={intl.formatMessage(messages.imdbuserscore, {
formattedCount: intl.formatNumber(
ratingData.imdb.criticsScoreCount,
{
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 1,
}
),
})}
>
<a
href={ratingData.imdb.url}
className="media-rating"

View File

@@ -152,7 +152,6 @@ const PWAHeader = ({ applicationTitle = 'Jellyseerr' }: PWAHeaderProps) => {
href="/apple-splash-1136-640.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"

View File

@@ -1,5 +1,6 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import Tooltip from '@app/components/Common/Tooltip';
import RequestModal from '@app/components/RequestModal';
import useRequestOverride from '@app/hooks/useRequestOverride';
@@ -95,36 +96,58 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
<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.requestedby)}>
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
</Tooltip>
<span className="w-40 truncate md:w-auto">
<span className="flex w-40 items-center truncate md:w-auto">
<Tooltip content={intl.formatMessage(messages.requestedby)}>
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
</Tooltip>
<Link
href={
request.requestedBy.id === user?.id
? '/profile'
: `/users/${request.requestedBy.id}`
}
className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
className="flex items-center font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
>
<span className="avatar-sm">
<CachedImage
type="avatar"
src={request.requestedBy.avatar}
alt=""
className="avatar-sm object-cover"
width={20}
height={20}
/>
</span>
{request.requestedBy.displayName}
</Link>
</span>
</div>
{request.modifiedBy && (
<div className="flex flex-nowrap">
<Tooltip content={intl.formatMessage(messages.lastmodifiedby)}>
<EyeIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
</Tooltip>
<span className="w-40 truncate md:w-auto">
<span className="flex w-40 items-center truncate md:w-auto">
<Tooltip
content={intl.formatMessage(messages.lastmodifiedby)}
>
<EyeIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
</Tooltip>
<Link
href={
request.modifiedBy.id === user?.id
? '/profile'
: `/users/${request.modifiedBy.id}`
}
className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
className="flex items-center font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
>
<span className="avatar-sm">
<CachedImage
type="avatar"
src={request.modifiedBy.avatar}
alt=""
className="avatar-sm object-cover"
width={20}
height={20}
/>
</span>
{request.modifiedBy.displayName}
</Link>
</span>

View File

@@ -343,7 +343,9 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const deleteMediaFile = async () => {
if (request.media) {
await axios.delete(`/api/v1/media/${request.media.id}/file`);
await axios.delete(
`/api/v1/media/${request.media.id}/file?is4k=${request.is4k}`
);
await axios.delete(`/api/v1/media/${request.media.id}`);
revalidateList();
}

View File

@@ -309,16 +309,19 @@ export const KeywordSelector = ({
const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>(
const keyword = await axios.get<Keyword | null>(
`/api/v1/keyword/${keywordId}`
);
return keyword.data;
})
);
const validKeywords: Keyword[] = keywords.filter(
(keyword): keyword is Keyword => keyword !== null
);
setDefaultDataValue(
keywords.map((keyword) => ({
validKeywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))

View File

@@ -113,12 +113,16 @@ const OverrideRuleTiles = ({
.flat()
.filter((keywordId) => keywordId)
.map(async (keywordId) => {
const response = await axios.get(`/api/v1/keyword/${keywordId}`);
const keyword: Keyword = response.data;
return keyword;
const response = await axios.get<Keyword | null>(
`/api/v1/keyword/${keywordId}`
);
return response.data;
})
);
setKeywords(keywords);
const validKeywords: Keyword[] = keywords.filter(
(keyword): keyword is Keyword => keyword !== null
);
setKeywords(validKeywords);
const allUsersFromRules = rules
.map((rule) => rule.users)
.filter((users) => users)

View File

@@ -22,6 +22,7 @@ import type {
import type { JobId } from '@server/lib/settings';
import axios from 'axios';
import cronstrue from 'cronstrue/i18n';
import { formatDuration, intervalToDuration } from 'date-fns';
import { Fragment, useReducer, useState } from 'react';
import type { MessageDescriptor } from 'react-intl';
import { FormattedRelativeTime, useIntl } from 'react-intl';
@@ -55,6 +56,25 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
cacheksize: 'Key Size',
cachevsize: 'Value Size',
flushcache: 'Flush Cache',
dnsCache: 'DNS Cache',
dnsCacheDescription:
'Jellyseerr caches DNS lookups to optimize performance and avoid making unnecessary API calls.',
dnscacheflushed: '{hostname} dns cache flushed.',
dnscachename: 'Hostname',
dnscacheactiveaddress: 'Active Address',
dnscachehits: 'Hits',
dnscachemisses: 'Misses',
dnscacheage: 'Age',
flushdnscache: 'Flush DNS Cache',
dnsCacheGlobalStats: 'Global DNS Cache Stats',
dnsCacheGlobalStatsDescription:
'These stats are aggregated across all DNS cache entries.',
size: 'Size',
hits: 'Hits',
misses: 'Misses',
failures: 'Failures',
ipv4Fallbacks: 'IPv4 Fallbacks',
hitRate: 'Hit Rate',
unknownJob: 'Unknown Job',
'plex-recently-added-scan': 'Plex Recently Added Scan',
'plex-full-scan': 'Plex Full Library Scan',
@@ -242,6 +262,18 @@ const SettingsJobs = () => {
cacheRevalidate();
};
const flushDnsCache = async (hostname: string) => {
await axios.post(`/api/v1/settings/cache/dns/${hostname}/flush`);
addToast(
intl.formatMessage(messages.dnscacheflushed, { hostname: hostname }),
{
appearance: 'success',
autoDismiss: true,
}
);
cacheRevalidate();
};
const scheduleJob = async () => {
const jobScheduleCron = ['0', '0', '*', '*', '*', '*'];
@@ -285,6 +317,18 @@ const SettingsJobs = () => {
}
};
const formatAge = (milliseconds: number): string => {
const duration = intervalToDuration({
start: 0,
end: milliseconds,
});
return formatDuration(duration, {
format: ['minutes', 'seconds'],
zero: false,
});
};
return (
<>
<PageTitle
@@ -567,6 +611,91 @@ const SettingsJobs = () => {
</Table.TBody>
</Table>
</div>
<div>
<h3 className="heading">{intl.formatMessage(messages.dnsCache)}</h3>
<p className="description">
{intl.formatMessage(messages.dnsCacheDescription)}
</p>
</div>
<div className="section">
<Table>
<thead>
<tr>
<Table.TH>{intl.formatMessage(messages.dnscachename)}</Table.TH>
<Table.TH>
{intl.formatMessage(messages.dnscacheactiveaddress)}
</Table.TH>
<Table.TH>{intl.formatMessage(messages.dnscachehits)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.dnscachemisses)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.dnscacheage)}</Table.TH>
<Table.TH></Table.TH>
</tr>
</thead>
<Table.TBody>
{Object.entries(cacheData?.dnsCache.entries || {}).map(
([hostname, data]) => (
<tr key={`cache-list-${hostname}`}>
<Table.TD>{hostname}</Table.TD>
<Table.TD>{data.activeAddress}</Table.TD>
<Table.TD>{intl.formatNumber(data.hits)}</Table.TD>
<Table.TD>{intl.formatNumber(data.misses)}</Table.TD>
<Table.TD>{formatAge(data.age)}</Table.TD>
<Table.TD alignText="right">
<Button
buttonType="danger"
onClick={() => flushDnsCache(hostname)}
>
<TrashIcon />
<span>{intl.formatMessage(messages.flushdnscache)}</span>
</Button>
</Table.TD>
</tr>
)
)}
</Table.TBody>
</Table>
</div>
<div>
<h3 className="heading">
{intl.formatMessage(messages.dnsCacheGlobalStats)}
</h3>
<p className="description">
{intl.formatMessage(messages.dnsCacheGlobalStatsDescription)}
</p>
</div>
<div className="section">
<Table>
<thead>
<tr>
{Object.entries(cacheData?.dnsCache.stats || {})
.filter(([statName]) => statName !== 'maxSize')
.map(([statName]) => (
<Table.TH key={`dns-stat-header-${statName}`}>
{messages[statName]
? intl.formatMessage(messages[statName])
: statName}
</Table.TH>
))}
</tr>
</thead>
<Table.TBody>
<tr>
{Object.entries(cacheData?.dnsCache.stats || {})
.filter(([statName]) => statName !== 'maxSize')
.map(([statName, statValue]) => (
<Table.TD key={`dns-stat-${statName}`}>
{statName === 'hitRate'
? intl.formatNumber(statValue, {
style: 'percent',
maximumFractionDigits: 2,
})
: intl.formatNumber(statValue)}
</Table.TD>
))}
</tr>
</Table.TBody>
</Table>
</div>
<div className="break-words">
<h3 className="heading">{intl.formatMessage(messages.imagecache)}</h3>
<p className="description">

View File

@@ -45,6 +45,13 @@ const messages = defineMessages('components.Settings.SettingsNetwork', {
forceIpv4First: 'Force IPv4 Resolution First',
forceIpv4FirstTip:
'Force Jellyseerr to resolve IPv4 addresses first instead of IPv6',
dnsCache: 'DNS Cache',
dnsCacheTip:
'Enable caching of DNS lookups to optimize performance and avoid making unnecessary API calls',
dnsCacheHoverTip:
'Do NOT enable this if you are experiencing issues with DNS lookups',
dnsCacheForceMinTtl: 'DNS Cache Minimum TTL',
dnsCacheForceMaxTtl: 'DNS Cache Maximum TTL',
});
const SettingsNetwork = () => {
@@ -90,6 +97,9 @@ const SettingsNetwork = () => {
initialValues={{
csrfProtection: data?.csrfProtection,
forceIpv4First: data?.forceIpv4First,
dnsCacheEnabled: data?.dnsCache.enabled,
dnsCacheForceMinTtl: data?.dnsCache.forceMinTtl,
dnsCacheForceMaxTtl: data?.dnsCache.forceMaxTtl,
trustProxy: data?.trustProxy,
proxyEnabled: data?.proxy?.enabled,
proxyHostname: data?.proxy?.hostname,
@@ -108,6 +118,11 @@ const SettingsNetwork = () => {
csrfProtection: values.csrfProtection,
forceIpv4First: values.forceIpv4First,
trustProxy: values.trustProxy,
dnsCache: {
enabled: values.dnsCacheEnabled,
forceMinTtl: values.dnsCacheForceMinTtl,
forceMaxTtl: values.dnsCacheForceMaxTtl,
},
proxy: {
enabled: values.proxyEnabled,
hostname: values.proxyHostname,
@@ -221,6 +236,90 @@ const SettingsNetwork = () => {
/>
</div>
</div>
<div className="form-row">
<label htmlFor="dnsCacheEnabled" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.dnsCache)}
</span>
<SettingsBadge badgeType="advanced" className="mr-2" />
<SettingsBadge badgeType="restartRequired" />
<SettingsBadge badgeType="experimental" className="mr-2" />
<span className="label-tip">
{intl.formatMessage(messages.dnsCacheTip)}
</span>
</label>
<div className="form-input-area">
<Tooltip
content={intl.formatMessage(messages.dnsCacheHoverTip)}
>
<Field
type="checkbox"
id="dnsCacheEnabled"
name="dnsCacheEnabled"
onChange={() => {
setFieldValue(
'dnsCacheEnabled',
!values.dnsCacheEnabled
);
}}
/>
</Tooltip>
</div>
</div>
{values.dnsCacheEnabled && (
<>
<div className="mr-2 ml-4">
<div className="form-row">
<label
htmlFor="dnsCacheForceMinTtl"
className="checkbox-label"
>
{intl.formatMessage(messages.dnsCacheForceMinTtl)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="dnsCacheForceMinTtl"
name="dnsCacheForceMinTtl"
type="text"
/>
</div>
{errors.dnsCacheForceMinTtl &&
touched.dnsCacheForceMinTtl &&
typeof errors.dnsCacheForceMinTtl === 'string' && (
<div className="error">
{errors.dnsCacheForceMinTtl}
</div>
)}
</div>
</div>
<div className="form-row">
<label
htmlFor="dnsCacheForceMaxTtl"
className="checkbox-label"
>
{intl.formatMessage(messages.dnsCacheForceMaxTtl)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="dnsCacheForceMaxTtl"
name="dnsCacheForceMaxTtl"
type="text"
/>
</div>
{errors.dnsCacheForceMaxTtl &&
touched.dnsCacheForceMaxTtl &&
typeof errors.dnsCacheForceMaxTtl === 'string' && (
<div className="error">
{errors.dnsCacheForceMaxTtl}
</div>
)}
</div>
</div>
</div>
</>
)}
<div className="form-row">
<label htmlFor="proxyEnabled" className="checkbox-label">
<span className="mr-2">

View File

@@ -34,37 +34,22 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
MediaServerType.NOT_CONFIGURED
);
const { user, revalidate } = useUser();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 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.
useEffect(() => {
const login = async () => {
if (!authToken) return;
const response = await axios.post('/api/v1/auth/plex', {
authToken: authToken,
});
setIsLoading(true);
setError(null);
try {
const response = await axios.post('/api/v1/auth/plex', {
authToken,
isSetup: true,
});
if (response.status >= 200 && response.status < 300) {
revalidate();
}
} catch (err) {
setError(
err.response?.data?.message ||
'Failed to connect to Plex. Please try again.'
);
} finally {
setIsLoading(false);
if (response.data?.email) {
revalidate();
}
};
if (authToken && mediaServerType === MediaServerType.PLEX) {
if (authToken && mediaServerType == MediaServerType.PLEX) {
login();
}
}, [authToken, mediaServerType, revalidate]);
@@ -73,7 +58,7 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
if (user) {
onComplete();
}
}, [user, onComplete]);
}, [user, mediaServerType, onComplete]);
return (
<div className="p-4">
@@ -89,20 +74,14 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
<FormattedMessage {...messages.signinWithPlex} />
)}
</div>
{error && (
<div className="mb-4 rounded bg-red-600 p-3 text-white">{error}</div>
)}
{serverType === MediaServerType.PLEX && (
<>
<div className="flex justify-center bg-black/30 px-10 py-8">
<PlexLoginButton
isProcessing={isLoading}
large
onAuthToken={(token) => {
onAuthToken={(authToken) => {
setMediaServerType(MediaServerType.PLEX);
setAuthToken(token);
setAuthToken(authToken);
}}
/>
</div>

View File

@@ -5,7 +5,7 @@ import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import axios from 'axios';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
@@ -16,31 +16,14 @@ interface PlexImportProps {
}
const messages = defineMessages('components.UserList', {
importfromplex: 'Import Plex Users & Profiles',
importfromplexerror:
'Something went wrong while importing Plex users and profiles.',
importfromplex: 'Import Plex Users',
importfromplexerror: 'Something went wrong while importing Plex users.',
importedfromplex:
'<strong>{userCount}</strong> Plex {userCount, plural, one {user} other {users}} imported successfully!',
user: 'User',
profile: 'Profile',
nouserstoimport: 'There are no Plex users or profiles to import.',
nouserstoimport: 'There are no Plex users to import.',
newplexsigninenabled:
'The <strong>Enable New Plex Sign-In</strong> setting is currently enabled. Plex users with library access do not need to be imported in order to sign in.',
possibleDuplicate: 'Possible duplicate',
duplicateUserWarning:
'This user appears to be a duplicate of an existing user or profile.',
duplicateProfileWarning:
'This profile appears to be a duplicate of an existing user or profile.',
importSuccess:
'{count, plural, one {# item was} other {# items were}} imported successfully.',
importSuccessUsers:
'{count, plural, one {# user was} other {# users were}} imported successfully.',
importSuccessProfiles:
'{count, plural, one {# profile was} other {# profiles were}} imported successfully.',
importSuccessMixed:
'{userCount, plural, one {# user} other {# users}} and {profileCount, plural, one {# profile} other {# profiles}} were imported successfully.',
skippedUsersDuplicates:
'{count, plural, one {# user was} other {# users were}} skipped due to duplicates.',
skippedProfilesDuplicates:
'{count, plural, one {# profile was} other {# profiles were}} skipped due to duplicates.',
});
const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
@@ -49,148 +32,44 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
const { addToast } = useToasts();
const [isImporting, setImporting] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
const [duplicateMap, setDuplicateMap] = useState<{
[key: string]: { type: 'user' | 'profile'; duplicateWith: string[] };
}>({});
const { data, error } = useSWR<{
users: {
const { data, error } = useSWR<
{
id: string;
title: string;
username: string;
email: string;
thumb: string;
}[];
profiles: {
id: string;
title: string;
username?: string;
thumb: string;
isMainUser?: boolean;
protected?: boolean;
}[];
}>('/api/v1/settings/plex/users', {
}[]
>(`/api/v1/settings/plex/users`, {
revalidateOnMount: true,
});
useEffect(() => {
if (data) {
const duplicates: {
[key: string]: { type: 'user' | 'profile'; duplicateWith: string[] };
} = {};
const usernameMap = new Map<string, string>();
data.users.forEach((user) => {
usernameMap.set(user.username.toLowerCase(), user.id);
});
data.profiles.forEach((profile) => {
const profileName = (profile.username || profile.title).toLowerCase();
if (usernameMap.has(profileName)) {
const userId = usernameMap.get(profileName);
duplicates[`profile-${profile.id}`] = {
type: 'profile',
duplicateWith: [`user-${userId}`],
};
duplicates[`user-${userId}`] = {
type: 'user',
duplicateWith: [`profile-${profile.id}`],
};
}
});
setDuplicateMap(duplicates);
}
}, [data]);
const importUsers = async () => {
setImporting(true);
try {
const { data: response } = await axios.post(
const { data: createdUsers } = await axios.post(
'/api/v1/user/import-from-plex',
{ plexIds: selectedUsers }
);
if (!Array.isArray(createdUsers) || createdUsers.length === 0) {
throw new Error('No users were imported from Plex.');
}
addToast(
intl.formatMessage(messages.importedfromplex, {
userCount: createdUsers.length,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
}),
{
plexIds: selectedUsers,
profileIds: selectedProfiles,
autoDismiss: true,
appearance: 'success',
}
);
if (response.data) {
const importedUsers = response.data.filter(
(item: { isPlexProfile: boolean }) => !item.isPlexProfile
).length;
const importedProfiles = response.data.filter(
(item: { isPlexProfile: boolean }) => item.isPlexProfile
).length;
let successMessage;
if (importedUsers > 0 && importedProfiles > 0) {
successMessage = intl.formatMessage(messages.importSuccessMixed, {
userCount: importedUsers,
profileCount: importedProfiles,
});
} else if (importedUsers > 0) {
successMessage = intl.formatMessage(messages.importSuccessUsers, {
count: importedUsers,
});
} else if (importedProfiles > 0) {
successMessage = intl.formatMessage(messages.importSuccessProfiles, {
count: importedProfiles,
});
} else {
successMessage = intl.formatMessage(messages.importSuccess, {
count: response.data.length,
});
}
let finalMessage = successMessage;
if (response.skipped && response.skipped.length > 0) {
const skippedUsers = response.skipped.filter(
(item: { type: string }) => item.type === 'user'
).length;
const skippedProfiles = response.skipped.filter(
(item: { type: string }) => item.type === 'profile'
).length;
let skippedMessage = '';
if (skippedUsers > 0) {
skippedMessage += intl.formatMessage(
messages.skippedUsersDuplicates,
{
count: skippedUsers,
}
);
}
if (skippedProfiles > 0) {
if (skippedMessage) skippedMessage += ' ';
skippedMessage += intl.formatMessage(
messages.skippedProfilesDuplicates,
{
count: skippedProfiles,
}
);
}
finalMessage += ` ${skippedMessage}`;
}
addToast(finalMessage, {
autoDismiss: true,
appearance: 'success',
});
if (onComplete) {
onComplete();
}
} else {
throw new Error('Invalid response format');
if (onComplete) {
onComplete();
}
} catch (e) {
addToast(intl.formatMessage(messages.importfromplexerror), {
@@ -205,116 +84,24 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
const isSelectedUser = (plexId: string): boolean =>
selectedUsers.includes(plexId);
const isSelectedProfile = (plexId: string): boolean =>
selectedProfiles.includes(plexId);
const isDuplicate = (type: 'user' | 'profile', id: string): boolean => {
const key = `${type}-${id}`;
return !!duplicateMap[key];
};
const isDuplicateWithSelected = (
type: 'user' | 'profile',
id: string
): boolean => {
const key = `${type}-${id}`;
if (!duplicateMap[key]) return false;
return duplicateMap[key].duplicateWith.some((dup) => {
if (dup.startsWith('user-')) {
const userId = dup.replace('user-', '');
return selectedUsers.includes(userId);
} else if (dup.startsWith('profile-')) {
const profileId = dup.replace('profile-', '');
return selectedProfiles.includes(profileId);
}
return false;
});
};
const hasSelectedDuplicate = (
type: 'user' | 'profile',
id: string
): boolean => {
if (type === 'user' && selectedUsers.includes(id)) {
return isDuplicateWithSelected('user', id);
} else if (type === 'profile' && selectedProfiles.includes(id)) {
return isDuplicateWithSelected('profile', id);
}
return false;
};
const isAllUsers = (): boolean =>
data?.users && data.users.length > 0
? selectedUsers.length === data.users.length
: false;
const isAllProfiles = (): boolean =>
data?.profiles && data.profiles.length > 0
? selectedProfiles.length === data.profiles.length
: false;
const isAllUsers = (): boolean => selectedUsers.length === data?.length;
const toggleUser = (plexId: string): void => {
if (selectedUsers.includes(plexId)) {
setSelectedUsers((users: string[]) =>
users.filter((user: string) => user !== plexId)
);
setSelectedUsers((users) => users.filter((user) => user !== plexId));
} else {
const willCreateDuplicate = isDuplicateWithSelected('user', plexId);
if (willCreateDuplicate) {
addToast(intl.formatMessage(messages.duplicateUserWarning), {
autoDismiss: true,
appearance: 'warning',
});
}
setSelectedUsers((users: string[]) => [...users, plexId]);
}
};
const toggleProfile = (plexId: string): void => {
if (selectedProfiles.includes(plexId)) {
setSelectedProfiles((profiles: string[]) =>
profiles.filter((profile: string) => profile !== plexId)
);
} else {
const willCreateDuplicate = isDuplicateWithSelected('profile', plexId);
if (willCreateDuplicate) {
addToast(intl.formatMessage(messages.duplicateProfileWarning), {
autoDismiss: true,
appearance: 'warning',
});
}
setSelectedProfiles((profiles: string[]) => [...profiles, plexId]);
setSelectedUsers((users) => [...users, plexId]);
}
};
const toggleAllUsers = (): void => {
if (data?.users && data.users.length > 0 && !isAllUsers()) {
setSelectedUsers(data.users.map((user) => user.id));
if (data && selectedUsers.length >= 0 && !isAllUsers()) {
setSelectedUsers(data.map((user) => user.id));
} else {
setSelectedUsers([]);
}
};
const toggleAllProfiles = (): void => {
if (data?.profiles && data.profiles.length > 0 && !isAllProfiles()) {
setSelectedProfiles(data.profiles.map((profile) => profile.id));
} else {
setSelectedProfiles([]);
}
};
const hasImportableContent =
(data?.users && data.users.length > 0) ||
(data?.profiles && data.profiles.length > 0);
const hasSelectedContent =
selectedUsers.length > 0 || selectedProfiles.length > 0;
return (
<Modal
loading={!data && !error}
@@ -322,13 +109,13 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
onOk={() => {
importUsers();
}}
okDisabled={isImporting || !hasSelectedContent}
okDisabled={isImporting || !selectedUsers.length}
okText={intl.formatMessage(
isImporting ? globalMessages.importing : globalMessages.import
)}
onCancel={onCancel}
>
{hasImportableContent ? (
{data?.length ? (
<>
{settings.currentSettings.newPlexLogin && (
<Alert
@@ -340,151 +127,57 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
type="info"
/>
)}
{/* Plex Users Section */}
{data?.users && data.users.length > 0 && (
<div className="mb-6 flex flex-col">
<h3 className="mb-2 text-lg font-medium">Plex Users</h3>
<div className="-mx-4 sm:mx-0">
<div className="inline-block min-w-full py-2 align-middle">
<div className="overflow-hidden shadow sm:rounded-lg">
<table className="min-w-full">
<thead>
<tr>
<th className="w-16 bg-gray-500 px-4 py-3">
<span
role="checkbox"
tabIndex={0}
aria-checked={isAllUsers()}
onClick={() => toggleAllUsers()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
toggleAllUsers();
}
}}
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
>
<span
aria-hidden="true"
className={`${
isAllUsers() ? 'bg-indigo-500' : 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
isAllUsers()
? '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>
</th>
<th className="bg-gray-500 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(messages.user)}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700 bg-gray-600">
{data.users.map((user) => (
<tr
key={`user-${user.id}`}
className={
hasSelectedDuplicate('user', user.id)
? 'bg-yellow-800/20'
: ''
}
<div className="flex flex-col">
<div className="-mx-4 sm:mx-0">
<div className="inline-block min-w-full py-2 align-middle">
<div className="overflow-hidden shadow sm:rounded-lg">
<table className="min-w-full">
<thead>
<tr>
<th className="w-16 bg-gray-500 px-4 py-3">
<span
role="checkbox"
tabIndex={0}
aria-checked={isAllUsers()}
onClick={() => toggleAllUsers()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
toggleAllUsers();
}
}}
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
>
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
<span
role="checkbox"
tabIndex={0}
aria-checked={isSelectedUser(user.id)}
onClick={() => toggleUser(user.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
toggleUser(user.id);
}
}}
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
>
<span
aria-hidden="true"
className={`${
isSelectedUser(user.id)
? 'bg-indigo-500'
: 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
isSelectedUser(user.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="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
<div className="flex items-center">
<Image
className="h-10 w-10 flex-shrink-0 rounded-full"
src={user.thumb}
alt=""
width={40}
height={40}
/>
<div className="ml-4">
<div className="flex items-center text-base font-bold leading-5">
{user.username}
{isDuplicate('user', user.id) && (
<span className="ml-2 rounded-full bg-yellow-600 px-2 py-0.5 text-xs font-normal">
{intl.formatMessage(
messages.possibleDuplicate
)}
</span>
)}
</div>
{user.username &&
user.username.toLowerCase() !==
user.email && (
<div className="text-sm leading-5 text-gray-300">
{user.email}
</div>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
{/* Plex Profiles Section */}
{data?.profiles && data.profiles.length > 0 && (
<div className="flex flex-col">
<h3 className="mb-2 text-lg font-medium">Plex Profiles</h3>
<div className="-mx-4 sm:mx-0">
<div className="inline-block min-w-full py-2 align-middle">
<div className="overflow-hidden shadow sm:rounded-lg">
<table className="min-w-full">
<thead>
<tr>
<th className="w-16 bg-gray-500 px-4 py-3">
<span
aria-hidden="true"
className={`${
isAllUsers() ? 'bg-indigo-500' : 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
isAllUsers() ? '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>
</th>
<th className="bg-gray-500 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(messages.user)}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700 bg-gray-600">
{data?.map((user) => (
<tr key={`user-${user.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={isAllProfiles()}
onClick={() => toggleAllProfiles()}
aria-checked={isSelectedUser(user.id)}
onClick={() => toggleUser(user.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
toggleAllProfiles();
toggleUser(user.id);
}
}}
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
@@ -492,7 +185,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
<span
aria-hidden="true"
className={`${
isAllProfiles()
isSelectedUser(user.id)
? 'bg-indigo-500'
: 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
@@ -500,96 +193,44 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
<span
aria-hidden="true"
className={`${
isAllProfiles()
isSelectedUser(user.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>
</th>
<th className="bg-gray-500 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(messages.profile)}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700 bg-gray-600">
{data.profiles.map((profile) => (
<tr
key={`profile-${profile.id}`}
className={
hasSelectedDuplicate('profile', profile.id)
? 'bg-yellow-800/20'
: ''
}
>
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
<span
role="checkbox"
tabIndex={0}
aria-checked={isSelectedProfile(profile.id)}
onClick={() => toggleProfile(profile.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
toggleProfile(profile.id);
}
}}
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
>
<span
aria-hidden="true"
className={`${
isSelectedProfile(profile.id)
? 'bg-indigo-500'
: 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
isSelectedProfile(profile.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="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
<div className="flex items-center">
<Image
className="h-10 w-10 flex-shrink-0 rounded-full"
src={profile.thumb}
alt=""
width={40}
height={40}
/>
<div className="ml-4">
<div className="flex items-center text-base font-bold leading-5">
{profile.title || profile.username}
{isDuplicate('profile', profile.id) && (
<span className="ml-2 rounded-full bg-yellow-600 px-2 py-0.5 text-xs font-normal">
{intl.formatMessage(
messages.possibleDuplicate
)}
</span>
)}
</div>
{profile.protected && (
</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
className="h-10 w-10 flex-shrink-0 rounded-full"
src={user.thumb}
alt=""
width={40}
height={40}
/>
<div className="ml-4">
<div className="text-base font-bold leading-5">
{user.username}
</div>
{user.username &&
user.username.toLowerCase() !==
user.email && (
<div className="text-sm leading-5 text-gray-300">
(PIN protected)
{user.email}
</div>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
</>
) : (
<Alert

View File

@@ -378,7 +378,7 @@ const UserProfile = () => {
{user.userType === UserType.PLEX &&
(user.id === currentUser?.id ||
currentHasPermission(Permission.ADMIN)) &&
(!watchData || !!watchData.recentlyWatched.length) &&
(!watchData || !!watchData.recentlyWatched?.length) &&
!watchDataError && (
<>
<div className="slider-header">
@@ -389,7 +389,7 @@ const UserProfile = () => {
<Slider
sliderKey="media"
isLoading={!watchData}
items={watchData?.recentlyWatched.map((item) => (
items={watchData?.recentlyWatched?.map((item) => (
<TmdbTitleCard
key={`media-slider-item-${item.id}`}
id={item.id}

View File

@@ -180,6 +180,7 @@
"components.IssueDetails.toaststatusupdated": "Issue status updated successfully!",
"components.IssueDetails.toaststatusupdatefailed": "Something went wrong while updating the issue status.",
"components.IssueDetails.unknownissuetype": "Unknown",
"components.IssueList.IssueItem.descriptionpreview": "Issue Description",
"components.IssueList.IssueItem.episodes": "{episodeCount, plural, one {Episode} other {Episodes}}",
"components.IssueList.IssueItem.issuestatus": "Status",
"components.IssueList.IssueItem.issuetype": "Type",
@@ -237,20 +238,7 @@
"components.Layout.VersionStatus.outofdate": "Out of Date",
"components.Layout.VersionStatus.streamdevelop": "Jellyseerr Develop",
"components.Layout.VersionStatus.streamstable": "Jellyseerr Stable",
"components.Login.PlexPinEntry.accessDenied": "Access denied.",
"components.Login.PlexPinEntry.cancel": "Cancel",
"components.Login.PlexPinEntry.invalidPin": "Invalid PIN. Please try again.",
"components.Login.PlexPinEntry.pinCheck": "Checking PIN...",
"components.Login.PlexPinEntry.pinDescription": "Enter the PIN for this profile",
"components.Login.PlexPinEntry.pinRequired": "PIN Required",
"components.Login.PlexPinEntry.submit": "Submit",
"components.Login.PlexProfileSelector.profile": "Profile",
"components.Login.PlexProfileSelector.selectProfile": "Select Profile",
"components.Login.PlexProfileSelector.selectProfileDescription": "Select which Plex profile you want to use",
"components.Login.PlexProfileSelector.selectProfileError": "Failed to select profile",
"components.Login.accessDenied": "Access denied.",
"components.Login.adminerror": "You must use an admin account to sign in.",
"components.Login.authFailed": "Authentication failed",
"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.",
@@ -261,7 +249,6 @@
"components.Login.hostname": "{mediaServerName} URL",
"components.Login.initialsignin": "Connect",
"components.Login.initialsigningin": "Connecting…",
"components.Login.invalidPin": "Invalid PIN. Please try again.",
"components.Login.invalidurlerror": "Unable to connect to {mediaServerName} server.",
"components.Login.loginerror": "Something went wrong while trying to sign in.",
"components.Login.loginwithapp": "Login with {appName}",
@@ -269,7 +256,6 @@
"components.Login.orsigninwith": "Or sign in with",
"components.Login.password": "Password",
"components.Login.port": "Port",
"components.Login.profileUserExists": "A profile user already exists for this Plex account. Please contact your administrator to resolve this duplicate.",
"components.Login.save": "Add",
"components.Login.saving": "Adding…",
"components.Login.servertype": "Server Type",
@@ -329,7 +315,7 @@
"components.MovieDetails.cast": "Cast",
"components.MovieDetails.digitalrelease": "Digital Release",
"components.MovieDetails.downloadstatus": "Download Status",
"components.MovieDetails.imdbuserscore": "IMDB User Score",
"components.MovieDetails.imdbuserscore": "IMDB User Score votes: {formattedCount}",
"components.MovieDetails.managemovie": "Manage Movie",
"components.MovieDetails.mark4kavailable": "Mark as Available in 4K",
"components.MovieDetails.markavailable": "Mark as Available",
@@ -888,6 +874,16 @@
"components.Settings.SettingsJobsCache.cachevsize": "Value Size",
"components.Settings.SettingsJobsCache.canceljob": "Cancel Job",
"components.Settings.SettingsJobsCache.command": "Command",
"components.Settings.SettingsJobsCache.dnsCache": "DNS Cache",
"components.Settings.SettingsJobsCache.dnsCacheDescription": "Jellyseerr caches DNS lookups to optimize performance and avoid making unnecessary API calls.",
"components.Settings.SettingsJobsCache.dnsCacheGlobalStats": "Global DNS Cache Stats",
"components.Settings.SettingsJobsCache.dnsCacheGlobalStatsDescription": "These stats are aggregated across all DNS cache entries.",
"components.Settings.SettingsJobsCache.dnscacheactiveaddress": "Active Address",
"components.Settings.SettingsJobsCache.dnscacheage": "Age",
"components.Settings.SettingsJobsCache.dnscacheflushed": "{hostname} dns cache flushed.",
"components.Settings.SettingsJobsCache.dnscachehits": "Hits",
"components.Settings.SettingsJobsCache.dnscachemisses": "Misses",
"components.Settings.SettingsJobsCache.dnscachename": "Hostname",
"components.Settings.SettingsJobsCache.download-sync": "Download Sync",
"components.Settings.SettingsJobsCache.download-sync-reset": "Download Sync Reset",
"components.Settings.SettingsJobsCache.editJobSchedule": "Modify Job",
@@ -897,12 +893,17 @@
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}",
"components.Settings.SettingsJobsCache.failures": "Failures",
"components.Settings.SettingsJobsCache.flushcache": "Flush Cache",
"components.Settings.SettingsJobsCache.flushdnscache": "Flush DNS Cache",
"components.Settings.SettingsJobsCache.hitRate": "Hit Rate",
"components.Settings.SettingsJobsCache.hits": "Hits",
"components.Settings.SettingsJobsCache.image-cache-cleanup": "Image Cache Cleanup",
"components.Settings.SettingsJobsCache.imagecache": "Image Cache",
"components.Settings.SettingsJobsCache.imagecacheDescription": "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>.",
"components.Settings.SettingsJobsCache.imagecachecount": "Images Cached",
"components.Settings.SettingsJobsCache.imagecachesize": "Total Cache Size",
"components.Settings.SettingsJobsCache.ipv4Fallbacks": "IPv4 Fallbacks",
"components.Settings.SettingsJobsCache.jellyfin-full-scan": "Jellyfin Full Library Scan",
"components.Settings.SettingsJobsCache.jellyfin-recently-added-scan": "Jellyfin Recently Added Scan",
"components.Settings.SettingsJobsCache.jobScheduleEditFailed": "Something went wrong while saving the job.",
@@ -914,6 +915,7 @@
"components.Settings.SettingsJobsCache.jobsandcache": "Jobs & Cache",
"components.Settings.SettingsJobsCache.jobstarted": "{jobname} started.",
"components.Settings.SettingsJobsCache.jobtype": "Type",
"components.Settings.SettingsJobsCache.misses": "Misses",
"components.Settings.SettingsJobsCache.nextexecution": "Next Execution",
"components.Settings.SettingsJobsCache.plex-full-scan": "Plex Full Library Scan",
"components.Settings.SettingsJobsCache.plex-recently-added-scan": "Plex Recently Added Scan",
@@ -923,6 +925,7 @@
"components.Settings.SettingsJobsCache.process-blacklisted-tags": "Process Blacklisted Tags",
"components.Settings.SettingsJobsCache.radarr-scan": "Radarr Scan",
"components.Settings.SettingsJobsCache.runnow": "Run Now",
"components.Settings.SettingsJobsCache.size": "Size",
"components.Settings.SettingsJobsCache.sonarr-scan": "Sonarr Scan",
"components.Settings.SettingsJobsCache.unknownJob": "Unknown Job",
"components.Settings.SettingsJobsCache.usersavatars": "Users' Avatars",
@@ -984,6 +987,11 @@
"components.Settings.SettingsNetwork.csrfProtection": "Enable CSRF Protection",
"components.Settings.SettingsNetwork.csrfProtectionHoverTip": "Do NOT enable this setting unless you understand what you are doing!",
"components.Settings.SettingsNetwork.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)",
"components.Settings.SettingsNetwork.dnsCache": "DNS Cache",
"components.Settings.SettingsNetwork.dnsCacheForceMaxTtl": "DNS Cache Maximum TTL",
"components.Settings.SettingsNetwork.dnsCacheForceMinTtl": "DNS Cache Minimum TTL",
"components.Settings.SettingsNetwork.dnsCacheHoverTip": "Do NOT enable this if you are experiencing issues with DNS lookups",
"components.Settings.SettingsNetwork.dnsCacheTip": "Enable caching of DNS lookups to optimize performance and avoid making unnecessary API calls",
"components.Settings.SettingsNetwork.docs": "documentation",
"components.Settings.SettingsNetwork.forceIpv4First": "Force IPv4 Resolution First",
"components.Settings.SettingsNetwork.forceIpv4FirstTip": "Force Jellyseerr to resolve IPv4 addresses first instead of IPv6",
@@ -1296,36 +1304,27 @@
"components.UserList.creating": "Creating…",
"components.UserList.deleteconfirm": "Are you sure you want to delete this user? All of their request data will be permanently removed.",
"components.UserList.deleteuser": "Delete User",
"components.UserList.duplicateProfileWarning": "This profile appears to be a duplicate of an existing user or profile.",
"components.UserList.duplicateUserWarning": "This user appears to be a duplicate of an existing user or profile.",
"components.UserList.edituser": "Edit User Permissions",
"components.UserList.email": "Email Address",
"components.UserList.importSuccess": "{count, plural, one {# item was} other {# items were}} imported successfully.",
"components.UserList.importSuccessMixed": "{userCount, plural, one {# user} other {# users}} and {profileCount, plural, one {# profile} other {# profiles}} were imported successfully.",
"components.UserList.importSuccessProfiles": "{count, plural, one {# profile was} other {# profiles were}} imported successfully.",
"components.UserList.importSuccessUsers": "{count, plural, one {# user was} other {# users were}} imported successfully.",
"components.UserList.importedfromJellyfin": "<strong>{userCount}</strong> {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!",
"components.UserList.importedfromplex": "<strong>{userCount}</strong> Plex {userCount, plural, one {user} other {users}} imported successfully!",
"components.UserList.importfromJellyfin": "Import {mediaServerName} Users",
"components.UserList.importfromJellyfinerror": "Something went wrong while importing {mediaServerName} users.",
"components.UserList.importfrommediaserver": "Import {mediaServerName} Users",
"components.UserList.importfromplex": "Import Plex Users & Profiles",
"components.UserList.importfromplexerror": "Something went wrong while importing Plex users and profiles.",
"components.UserList.importfromplex": "Import Plex Users",
"components.UserList.importfromplexerror": "Something went wrong while importing Plex users.",
"components.UserList.localLoginDisabled": "The <strong>Enable Local Sign-In</strong> setting is currently disabled.",
"components.UserList.localuser": "Local User",
"components.UserList.mediaServerUser": "{mediaServerName} User",
"components.UserList.newJellyfinsigninenabled": "The <strong>Enable New {mediaServerName} Sign-In</strong> setting is currently enabled. {mediaServerName} users with library access do not need to be imported in order to sign in.",
"components.UserList.newplexsigninenabled": "The <strong>Enable New Plex Sign-In</strong> setting is currently enabled. Plex users with library access do not need to be imported in order to sign in.",
"components.UserList.noJellyfinuserstoimport": "There are no {mediaServerName} users to import.",
"components.UserList.nouserstoimport": "There are no Plex users or profiles to import.",
"components.UserList.nouserstoimport": "There are no Plex users to import.",
"components.UserList.owner": "Owner",
"components.UserList.password": "Password",
"components.UserList.passwordinfodescription": "Configure an application URL and enable email notifications to allow automatic password generation.",
"components.UserList.plexuser": "Plex User",
"components.UserList.possibleDuplicate": "Possible duplicate",
"components.UserList.profile": "Profile",
"components.UserList.role": "Role",
"components.UserList.skippedProfilesDuplicates": "{count, plural, one {# profile was} other {# profiles were}} skipped due to duplicates.",
"components.UserList.skippedUsersDuplicates": "{count, plural, one {# user was} other {# users were}} skipped due to duplicates.",
"components.UserList.sortCreated": "Join Date",
"components.UserList.sortDisplayName": "Display Name",
"components.UserList.sortRequests": "Request Count",