mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 10:49:30 -05:00
Compare commits
28 Commits
preview-dn
...
preview-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57135b39c6 | ||
|
|
c6f98a84d4 | ||
|
|
718c64f973 | ||
|
|
c6ab5c56ad | ||
|
|
268f931844 | ||
|
|
93e379ac68 | ||
|
|
6380c951b4 | ||
|
|
c071e1f1fd | ||
|
|
e79dca33fa | ||
|
|
c2a61862c1 | ||
|
|
eaa3691671 | ||
|
|
0aa3f293bc | ||
|
|
5ed3269bbb | ||
|
|
f3b9b873ed | ||
|
|
6ac0445f8b | ||
|
|
46c871c3cf | ||
|
|
7da109e556 | ||
|
|
1374f30ca9 | ||
|
|
3a58649122 | ||
|
|
2f0a11bafe | ||
|
|
a234d57335 | ||
|
|
7f28834073 | ||
|
|
0a6c2ee9cc | ||
|
|
62b1bfcd89 | ||
|
|
88a9848249 | ||
|
|
a0fa320056 | ||
|
|
acc059c0aa | ||
|
|
f5089502b9 |
@@ -4,7 +4,6 @@ dist/
|
||||
config/
|
||||
CHANGELOG.md
|
||||
pnpm-lock.yaml
|
||||
cypress/config/settings.cypress.json
|
||||
|
||||
# assets
|
||||
src/assets/
|
||||
|
||||
@@ -21,11 +21,5 @@ module.exports = {
|
||||
rangeEnd: 0, // default: Infinity
|
||||
},
|
||||
},
|
||||
{
|
||||
files: 'cypress/config/settings.cypress.json',
|
||||
options: {
|
||||
rangeEnd: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -3,8 +3,8 @@ kubeVersion: ">=1.23.0-0"
|
||||
name: jellyseerr-chart
|
||||
description: Jellyseerr helm chart for Kubernetes
|
||||
type: application
|
||||
version: 2.6.2
|
||||
appVersion: "2.7.3"
|
||||
version: 2.6.1
|
||||
appVersion: "2.7.1"
|
||||
maintainers:
|
||||
- name: Jellyseerr
|
||||
url: https://github.com/Fallenbagel/jellyseerr
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# jellyseerr-chart
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
Jellyseerr helm chart for Kubernetes
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"apiKey": "testkey",
|
||||
"applicationTitle": "Jellyseerr",
|
||||
"applicationUrl": "",
|
||||
"csrfProtection": false,
|
||||
"cacheImages": false,
|
||||
"defaultPermissions": 32,
|
||||
"defaultQuotas": {
|
||||
@@ -179,26 +180,5 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
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).
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Jobs & Cache
|
||||
description: Configure jobs and cache settings.
|
||||
sidebar_position: 6
|
||||
---
|
||||
|
||||
# Jobs & Cache
|
||||
|
||||
@@ -133,6 +133,18 @@ 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
|
||||
@@ -141,83 +153,14 @@ 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
|
||||
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
|
||||
streamingRegion:
|
||||
type: string
|
||||
MainSettings:
|
||||
type: object
|
||||
properties:
|
||||
@@ -260,51 +203,30 @@ components:
|
||||
csrfProtection:
|
||||
type: boolean
|
||||
example: false
|
||||
forceIpv4First:
|
||||
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:
|
||||
type: boolean
|
||||
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
|
||||
protected:
|
||||
type: boolean
|
||||
example: true
|
||||
PlexLibrary:
|
||||
type: object
|
||||
properties:
|
||||
@@ -3009,68 +2931,6 @@ 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:
|
||||
@@ -3110,21 +2970,6 @@ 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
|
||||
@@ -3846,17 +3691,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.
|
||||
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.
|
||||
security: []
|
||||
tags:
|
||||
- auth
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -3866,8 +3711,155 @@ 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
|
||||
@@ -4657,7 +4649,11 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserSettings'
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
example: 'Mr User'
|
||||
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.
|
||||
@@ -4674,14 +4670,22 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserSettings'
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
nullable: true
|
||||
responses:
|
||||
'200':
|
||||
description: Updated user general settings returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserSettings'
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
example: 'Mr User'
|
||||
/user/{userId}/settings/password:
|
||||
get:
|
||||
summary: Get password page informatiom
|
||||
@@ -6775,16 +6779,9 @@ 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: Successfully removed media item
|
||||
description: Succesfully removed media item
|
||||
/media/{mediaId}/{status}:
|
||||
post:
|
||||
summary: Update media status
|
||||
@@ -7451,22 +7448,11 @@ paths:
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Keyword returned (null if not found)
|
||||
description: Keyword returned
|
||||
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
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
"cronstrue": "2.23.0",
|
||||
"date-fns": "2.29.3",
|
||||
"dayjs": "1.11.7",
|
||||
"dns-caching": "^0.2.4",
|
||||
"email-templates": "12.0.1",
|
||||
"email-validator": "2.0.4",
|
||||
"express": "4.21.2",
|
||||
|
||||
214
pnpm-lock.yaml
generated
214
pnpm-lock.yaml
generated
@@ -83,9 +83,6 @@ importers:
|
||||
dayjs:
|
||||
specifier: 1.11.7
|
||||
version: 1.11.7
|
||||
dns-caching:
|
||||
specifier: ^0.2.4
|
||||
version: 0.2.4
|
||||
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)
|
||||
@@ -691,8 +688,8 @@ packages:
|
||||
resolution: {integrity: sha512-NlmJJtvcw72yRJRcnCmGvSi+3jDEg8qFu3z0AFoymmzLx5ERVWyzd9kVXr7Th9/8yIJi2Zc6av4Tqz3wFs8QWg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helpers@7.28.2':
|
||||
resolution: {integrity: sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==}
|
||||
'@babel/helpers@7.27.6':
|
||||
resolution: {integrity: sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/highlight@7.24.7':
|
||||
@@ -1458,8 +1455,8 @@ packages:
|
||||
resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/runtime@7.28.2':
|
||||
resolution: {integrity: sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==}
|
||||
'@babel/runtime@7.27.6':
|
||||
resolution: {integrity: sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.24.7':
|
||||
@@ -1486,8 +1483,8 @@ packages:
|
||||
resolution: {integrity: sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.28.2':
|
||||
resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
|
||||
'@babel/types@7.28.1':
|
||||
resolution: {integrity: sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@codedependant/semantic-release-docker@5.1.0':
|
||||
@@ -1978,8 +1975,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.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
'@jridgewell/gen-mapping@0.3.12':
|
||||
resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.5':
|
||||
resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
|
||||
@@ -1993,20 +1990,20 @@ packages:
|
||||
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/source-map@0.3.11':
|
||||
resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==}
|
||||
'@jridgewell/source-map@0.3.10':
|
||||
resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.4.15':
|
||||
resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
'@jridgewell/sourcemap-codec@1.5.4':
|
||||
resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.30':
|
||||
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
|
||||
'@jridgewell/trace-mapping@0.3.29':
|
||||
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
@@ -3232,8 +3229,8 @@ packages:
|
||||
'@swc/helpers@0.5.5':
|
||||
resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
|
||||
|
||||
'@swc/types@0.1.24':
|
||||
resolution: {integrity: sha512-tjTMh3V4vAORHtdTprLlfoMptu1WfTZG9Rsca6yOKyNYsRr+MUXutKmliB17orgSZk5DpnDxs8GUdd/qwYxOng==}
|
||||
'@swc/types@0.1.23':
|
||||
resolution: {integrity: sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==}
|
||||
|
||||
'@tailwindcss/aspect-ratio@0.4.2':
|
||||
resolution: {integrity: sha512-8QPrypskfBa7QIMuKHg2TA7BqES6vhBrDLOv8Unb6FcFyd3TjKbc6lcmb9UPQHxfl24sXoJ41ux/H7qQQvfaSQ==}
|
||||
@@ -3394,8 +3391,8 @@ packages:
|
||||
'@types/node@17.0.45':
|
||||
resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==}
|
||||
|
||||
'@types/node@18.19.122':
|
||||
resolution: {integrity: sha512-yzegtT82dwTNEe/9y+CM8cgb42WrUfMMCg2QqSddzO1J6uPmBD7qKCZ7dOHZP2Yrpm/kb0eqdNMn2MUyEiqBmA==}
|
||||
'@types/node@18.19.118':
|
||||
resolution: {integrity: sha512-hIPK0hSrrcaoAu/gJMzN3QClXE4QdCdFvaenJ0JsjIbExP1JFFVH+RHcBt25c9n8bx5dkIfqKE+uw6BmBns7ug==}
|
||||
|
||||
'@types/node@20.5.1':
|
||||
resolution: {integrity: sha512-4tT2UrL5LBqDwoed9wZ6N3umC4Yhz3W3FloMmiiG4JwmUJWpie0c7lcnUNd4gtMKuDEO4wRVS8B6Xa0uMRsMKg==}
|
||||
@@ -4090,6 +4087,11 @@ 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}
|
||||
@@ -4194,8 +4196,8 @@ packages:
|
||||
caniuse-lite@1.0.30001700:
|
||||
resolution: {integrity: sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==}
|
||||
|
||||
caniuse-lite@1.0.30001734:
|
||||
resolution: {integrity: sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==}
|
||||
caniuse-lite@1.0.30001727:
|
||||
resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==}
|
||||
|
||||
caseless@0.12.0:
|
||||
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
|
||||
@@ -4424,8 +4426,8 @@ packages:
|
||||
resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
compression@1.8.1:
|
||||
resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==}
|
||||
compression@1.8.0:
|
||||
resolution: {integrity: sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
computed-style@0.1.4:
|
||||
@@ -4545,8 +4547,8 @@ packages:
|
||||
core-js-compat@3.37.1:
|
||||
resolution: {integrity: sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==}
|
||||
|
||||
core-js-compat@3.45.0:
|
||||
resolution: {integrity: sha512-gRoVMBawZg0OnxaVv3zpqLLxaHmsubEGyTnqdpI/CEBvX4JadI1dMSHxagThprYRtSVbuQxvi6iUatdPxohHpA==}
|
||||
core-js-compat@3.44.0:
|
||||
resolution: {integrity: sha512-JepmAj2zfl6ogy34qfWtcE7nHKAJnKsQFRn++scjVS2bZFllwptzw61BZcZFYBPpUznLfAvh0LGhxKppk04ClA==}
|
||||
|
||||
core-util-is@1.0.2:
|
||||
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
|
||||
@@ -4864,9 +4866,6 @@ packages:
|
||||
dlv@1.1.3:
|
||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||
|
||||
dns-caching@0.2.4:
|
||||
resolution: {integrity: sha512-J48CLnMScOAtWIdExkz+522A0nPUwG5o+w7vVsXBJDipVLugCnps5AVJMn9bOkqQm4GarHtutMHYJEryCTeMjA==}
|
||||
|
||||
doctrine@2.1.0:
|
||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4938,8 +4937,8 @@ packages:
|
||||
electron-to-chromium@1.4.810:
|
||||
resolution: {integrity: sha512-Kaxhu4T7SJGpRQx99tq216gCq2nMxJo+uuT6uzz9l8TVN2stL7M06MIIXAtr9jsrLs2Glflgf2vMQRepxawOdQ==}
|
||||
|
||||
electron-to-chromium@1.5.200:
|
||||
resolution: {integrity: sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==}
|
||||
electron-to-chromium@1.5.182:
|
||||
resolution: {integrity: sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==}
|
||||
|
||||
email-templates@12.0.1:
|
||||
resolution: {integrity: sha512-849pjBFVUAWWTa3HqhDjxlXHaSWmxf4CZOlZ9iVkrSAbQ8YCYi+7KiKqt35L6F20WhSViWX7lmMjno6zBv2rNQ==}
|
||||
@@ -5507,8 +5506,8 @@ packages:
|
||||
flow-enums-runtime@0.0.6:
|
||||
resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==}
|
||||
|
||||
flow-parser@0.278.0:
|
||||
resolution: {integrity: sha512-9oUcYDHf9n+E/t0FXndgBqGbaUsGEcmWqIr1ldqCzTzctsJV5E/bHusOj4ThB72Ss2mqWpLFNz0+o2c1O8J6+A==}
|
||||
flow-parser@0.275.0:
|
||||
resolution: {integrity: sha512-fHNwawoA2LM7FsxhU/1lTRGq9n6/Q8k861eHgN7GKtamYt9Qrxpg/ZSrev8o1WX7fQ2D3Gg3+uvYN15PmsG7Yw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
fn.name@1.1.0:
|
||||
@@ -6745,10 +6744,6 @@ 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==}
|
||||
|
||||
@@ -7539,10 +7534,6 @@ 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==}
|
||||
|
||||
@@ -8847,9 +8838,9 @@ packages:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
source-map@0.7.6:
|
||||
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
|
||||
engines: {node: '>= 12'}
|
||||
source-map@0.7.4:
|
||||
resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
space-separated-tokens@2.0.2:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
@@ -9902,8 +9893,8 @@ packages:
|
||||
engines: {node: '>= 14'}
|
||||
hasBin: true
|
||||
|
||||
yaml@2.8.1:
|
||||
resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==}
|
||||
yaml@2.8.0:
|
||||
resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
@@ -10047,11 +10038,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.28.2
|
||||
'@babel/helpers': 7.27.6
|
||||
'@babel/parser': 7.28.0
|
||||
'@babel/template': 7.27.2
|
||||
'@babel/traverse': 7.28.0
|
||||
'@babel/types': 7.28.2
|
||||
'@babel/types': 7.28.1
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.1
|
||||
gensync: 1.0.0-beta.2
|
||||
@@ -10070,9 +10061,9 @@ snapshots:
|
||||
'@babel/generator@7.28.0':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.0
|
||||
'@babel/types': 7.28.2
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.30
|
||||
'@babel/types': 7.28.1
|
||||
'@jridgewell/gen-mapping': 0.3.12
|
||||
'@jridgewell/trace-mapping': 0.3.29
|
||||
jsesc: 3.1.0
|
||||
|
||||
'@babel/helper-annotate-as-pure@7.24.7':
|
||||
@@ -10081,7 +10072,7 @@ snapshots:
|
||||
|
||||
'@babel/helper-annotate-as-pure@7.27.3':
|
||||
dependencies:
|
||||
'@babel/types': 7.28.2
|
||||
'@babel/types': 7.28.1
|
||||
|
||||
'@babel/helper-builder-binary-assignment-operator-visitor@7.24.7':
|
||||
dependencies:
|
||||
@@ -10102,7 +10093,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/compat-data': 7.28.0
|
||||
'@babel/helper-validator-option': 7.27.1
|
||||
browserslist: 4.25.1
|
||||
browserslist: 4.24.3
|
||||
lru-cache: 5.1.1
|
||||
semver: 6.3.1
|
||||
|
||||
@@ -10208,7 +10199,7 @@ snapshots:
|
||||
'@babel/helper-member-expression-to-functions@7.27.1':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.28.0
|
||||
'@babel/types': 7.28.2
|
||||
'@babel/types': 7.28.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -10222,7 +10213,7 @@ snapshots:
|
||||
'@babel/helper-module-imports@7.27.1':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.28.0
|
||||
'@babel/types': 7.28.2
|
||||
'@babel/types': 7.28.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -10261,7 +10252,7 @@ snapshots:
|
||||
|
||||
'@babel/helper-optimise-call-expression@7.27.1':
|
||||
dependencies:
|
||||
'@babel/types': 7.28.2
|
||||
'@babel/types': 7.28.1
|
||||
|
||||
'@babel/helper-plugin-utils@7.24.7': {}
|
||||
|
||||
@@ -10329,7 +10320,7 @@ snapshots:
|
||||
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.28.0
|
||||
'@babel/types': 7.28.2
|
||||
'@babel/types': 7.28.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -10366,7 +10357,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/template': 7.27.2
|
||||
'@babel/traverse': 7.28.0
|
||||
'@babel/types': 7.28.2
|
||||
'@babel/types': 7.28.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -10375,10 +10366,10 @@ snapshots:
|
||||
'@babel/template': 7.24.7
|
||||
'@babel/types': 7.24.7
|
||||
|
||||
'@babel/helpers@7.28.2':
|
||||
'@babel/helpers@7.27.6':
|
||||
dependencies:
|
||||
'@babel/template': 7.27.2
|
||||
'@babel/types': 7.28.2
|
||||
'@babel/types': 7.28.1
|
||||
|
||||
'@babel/highlight@7.24.7':
|
||||
dependencies:
|
||||
@@ -10397,7 +10388,7 @@ snapshots:
|
||||
|
||||
'@babel/parser@7.28.0':
|
||||
dependencies:
|
||||
'@babel/types': 7.28.2
|
||||
'@babel/types': 7.28.1
|
||||
|
||||
'@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.24.7(@babel/core@7.24.7)':
|
||||
dependencies:
|
||||
@@ -11089,7 +11080,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.2
|
||||
'@babel/types': 7.28.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -11383,7 +11374,7 @@ snapshots:
|
||||
dependencies:
|
||||
regenerator-runtime: 0.14.1
|
||||
|
||||
'@babel/runtime@7.28.2': {}
|
||||
'@babel/runtime@7.27.6': {}
|
||||
|
||||
'@babel/template@7.24.7':
|
||||
dependencies:
|
||||
@@ -11395,7 +11386,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
'@babel/parser': 7.28.0
|
||||
'@babel/types': 7.28.2
|
||||
'@babel/types': 7.28.1
|
||||
|
||||
'@babel/traverse@7.24.7':
|
||||
dependencies:
|
||||
@@ -11419,7 +11410,7 @@ snapshots:
|
||||
'@babel/helper-globals': 7.28.0
|
||||
'@babel/parser': 7.28.0
|
||||
'@babel/template': 7.27.2
|
||||
'@babel/types': 7.28.2
|
||||
'@babel/types': 7.28.1
|
||||
debug: 4.4.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -11435,7 +11426,7 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.25.9
|
||||
'@babel/helper-validator-identifier': 7.25.9
|
||||
|
||||
'@babel/types@7.28.2':
|
||||
'@babel/types@7.28.1':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.27.1
|
||||
@@ -12100,10 +12091,10 @@ snapshots:
|
||||
'@types/yargs': 17.0.33
|
||||
chalk: 4.1.2
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
'@jridgewell/gen-mapping@0.3.12':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/trace-mapping': 0.3.30
|
||||
'@jridgewell/sourcemap-codec': 1.5.4
|
||||
'@jridgewell/trace-mapping': 0.3.29
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.5':
|
||||
dependencies:
|
||||
@@ -12115,24 +12106,24 @@ snapshots:
|
||||
|
||||
'@jridgewell/set-array@1.2.1': {}
|
||||
|
||||
'@jridgewell/source-map@0.3.11':
|
||||
'@jridgewell/source-map@0.3.10':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.30
|
||||
'@jridgewell/gen-mapping': 0.3.12
|
||||
'@jridgewell/trace-mapping': 0.3.29
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.4.15': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
'@jridgewell/sourcemap-codec@1.5.4': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.4.15
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.30':
|
||||
'@jridgewell/trace-mapping@0.3.29':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/sourcemap-codec': 1.5.4
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
dependencies:
|
||||
@@ -12880,7 +12871,7 @@ snapshots:
|
||||
semver: 7.7.1
|
||||
strip-ansi: 5.2.0
|
||||
wcwidth: 1.0.1
|
||||
yaml: 2.8.1
|
||||
yaml: 2.8.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
@@ -12925,7 +12916,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.1
|
||||
compression: 1.8.0
|
||||
connect: 3.7.0
|
||||
errorhandler: 1.5.1
|
||||
nocache: 3.0.4
|
||||
@@ -13401,7 +13392,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.28.2
|
||||
'@babel/runtime': 7.27.6
|
||||
'@types/react-reconciler': 0.26.7
|
||||
'@types/webxr': 0.5.22
|
||||
base64-js: 1.5.1
|
||||
@@ -13556,7 +13547,7 @@ snapshots:
|
||||
|
||||
'@rnx-kit/chromium-edge-launcher@1.0.0':
|
||||
dependencies:
|
||||
'@types/node': 18.19.122
|
||||
'@types/node': 18.19.118
|
||||
escape-string-regexp: 4.0.0
|
||||
is-wsl: 2.2.0
|
||||
lighthouse-logger: 1.4.2
|
||||
@@ -13819,7 +13810,7 @@ snapshots:
|
||||
'@swc/core@1.6.5(@swc/helpers@0.5.11)':
|
||||
dependencies:
|
||||
'@swc/counter': 0.1.3
|
||||
'@swc/types': 0.1.24
|
||||
'@swc/types': 0.1.23
|
||||
optionalDependencies:
|
||||
'@swc/core-darwin-arm64': 1.6.5
|
||||
'@swc/core-darwin-x64': 1.6.5
|
||||
@@ -13844,7 +13835,7 @@ snapshots:
|
||||
'@swc/counter': 0.1.3
|
||||
tslib: 2.8.1
|
||||
|
||||
'@swc/types@0.1.24':
|
||||
'@swc/types@0.1.23':
|
||||
dependencies:
|
||||
'@swc/counter': 0.1.3
|
||||
|
||||
@@ -14022,7 +14013,7 @@ snapshots:
|
||||
|
||||
'@types/node@17.0.45': {}
|
||||
|
||||
'@types/node@18.19.122':
|
||||
'@types/node@18.19.118':
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
@@ -14698,7 +14689,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.45.0
|
||||
core-js-compat: 3.44.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -14813,10 +14804,17 @@ 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.30001734
|
||||
electron-to-chromium: 1.5.200
|
||||
caniuse-lite: 1.0.30001727
|
||||
electron-to-chromium: 1.5.182
|
||||
node-releases: 2.0.19
|
||||
update-browserslist-db: 1.1.3(browserslist@4.25.1)
|
||||
|
||||
@@ -14947,7 +14945,7 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001700: {}
|
||||
|
||||
caniuse-lite@1.0.30001734: {}
|
||||
caniuse-lite@1.0.30001727: {}
|
||||
|
||||
caseless@0.12.0: {}
|
||||
|
||||
@@ -15197,13 +15195,13 @@ snapshots:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
compression@1.8.1:
|
||||
compression@1.8.0:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
compressible: 2.0.18
|
||||
debug: 2.6.9
|
||||
negotiator: 0.6.4
|
||||
on-headers: 1.1.0
|
||||
on-headers: 1.0.2
|
||||
safe-buffer: 5.2.1
|
||||
vary: 1.1.2
|
||||
transitivePeerDependencies:
|
||||
@@ -15334,7 +15332,7 @@ snapshots:
|
||||
dependencies:
|
||||
browserslist: 4.23.1
|
||||
|
||||
core-js-compat@3.45.0:
|
||||
core-js-compat@3.44.0:
|
||||
dependencies:
|
||||
browserslist: 4.25.1
|
||||
|
||||
@@ -15700,10 +15698,6 @@ snapshots:
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
||||
dns-caching@0.2.4:
|
||||
dependencies:
|
||||
lru-cache: 11.1.0
|
||||
|
||||
doctrine@2.1.0:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
@@ -15788,7 +15782,7 @@ snapshots:
|
||||
|
||||
electron-to-chromium@1.4.810: {}
|
||||
|
||||
electron-to-chromium@1.5.200: {}
|
||||
electron-to-chromium@1.5.182: {}
|
||||
|
||||
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:
|
||||
@@ -16708,7 +16702,7 @@ snapshots:
|
||||
|
||||
flow-enums-runtime@0.0.6: {}
|
||||
|
||||
flow-parser@0.278.0: {}
|
||||
flow-parser@0.275.0: {}
|
||||
|
||||
fn.name@1.1.0: {}
|
||||
|
||||
@@ -17098,7 +17092,7 @@ snapshots:
|
||||
|
||||
hermes-profile-transformer@0.0.6:
|
||||
dependencies:
|
||||
source-map: 0.7.6
|
||||
source-map: 0.7.4
|
||||
|
||||
highlight.js@10.7.3: {}
|
||||
|
||||
@@ -17691,7 +17685,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.278.0
|
||||
flow-parser: 0.275.0
|
||||
graceful-fs: 4.2.11
|
||||
micromatch: 4.0.8
|
||||
neo-async: 2.6.2
|
||||
@@ -18042,8 +18036,6 @@ snapshots:
|
||||
|
||||
lru-cache@10.2.2: {}
|
||||
|
||||
lru-cache@11.1.0: {}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
@@ -18315,13 +18307,13 @@ snapshots:
|
||||
|
||||
metro-runtime@0.80.12:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.2
|
||||
'@babel/runtime': 7.27.6
|
||||
flow-enums-runtime: 0.0.6
|
||||
|
||||
metro-source-map@0.80.12:
|
||||
dependencies:
|
||||
'@babel/traverse': 7.28.0
|
||||
'@babel/types': 7.28.2
|
||||
'@babel/types': 7.28.1
|
||||
flow-enums-runtime: 0.0.6
|
||||
invariant: 2.2.4
|
||||
metro-symbolicate: 0.80.12
|
||||
@@ -18360,7 +18352,7 @@ snapshots:
|
||||
'@babel/core': 7.28.0
|
||||
'@babel/generator': 7.28.0
|
||||
'@babel/parser': 7.28.0
|
||||
'@babel/types': 7.28.2
|
||||
'@babel/types': 7.28.1
|
||||
flow-enums-runtime: 0.0.6
|
||||
metro: 0.80.12
|
||||
metro-babel-transformer: 0.80.12
|
||||
@@ -18383,7 +18375,7 @@ snapshots:
|
||||
'@babel/parser': 7.28.0
|
||||
'@babel/template': 7.27.2
|
||||
'@babel/traverse': 7.28.0
|
||||
'@babel/types': 7.28.2
|
||||
'@babel/types': 7.28.1
|
||||
accepts: 1.3.8
|
||||
chalk: 4.1.2
|
||||
ci-info: 2.0.0
|
||||
@@ -19010,8 +19002,6 @@ snapshots:
|
||||
|
||||
on-headers@1.0.2: {}
|
||||
|
||||
on-headers@1.1.0: {}
|
||||
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
@@ -20544,7 +20534,7 @@ snapshots:
|
||||
|
||||
source-map@0.6.1: {}
|
||||
|
||||
source-map@0.7.6: {}
|
||||
source-map@0.7.4: {}
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
@@ -20881,7 +20871,7 @@ snapshots:
|
||||
|
||||
terser@5.43.1:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.11
|
||||
'@jridgewell/source-map': 0.3.10
|
||||
acorn: 8.15.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
@@ -21308,6 +21298,12 @@ 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
|
||||
@@ -21598,7 +21594,7 @@ snapshots:
|
||||
|
||||
yaml@2.4.5: {}
|
||||
|
||||
yaml@2.8.1: {}
|
||||
yaml@2.8.0: {}
|
||||
|
||||
yamljs@0.3.0:
|
||||
dependencies:
|
||||
|
||||
@@ -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,6 +31,37 @@ 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 {
|
||||
$: {
|
||||
@@ -225,6 +256,156 @@ 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();
|
||||
|
||||
@@ -291,7 +472,7 @@ class PlexTvAPI extends ExternalAPI {
|
||||
headers: {
|
||||
'If-None-Match': cachedWatchlist?.etag,
|
||||
},
|
||||
baseURL: 'https://discover.provider.plex.tv',
|
||||
baseURL: 'https://metadata.provider.plex.tv',
|
||||
validateStatus: (status) => status < 400, // Allow HTTP 304 to return without error
|
||||
}
|
||||
);
|
||||
@@ -315,7 +496,7 @@ class PlexTvAPI extends ExternalAPI {
|
||||
const detailedResponse = await this.getRolling<MetadataResponse>(
|
||||
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||
{
|
||||
baseURL: 'https://discover.provider.plex.tv',
|
||||
baseURL: 'https://metadata.provider.plex.tv',
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1054,7 +1054,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
keywordId,
|
||||
}: {
|
||||
keywordId: number;
|
||||
}): Promise<TmdbKeyword | null> {
|
||||
}): Promise<TmdbKeyword> {
|
||||
try {
|
||||
const data = await this.get<TmdbKeyword>(
|
||||
`/keyword/${keywordId}`,
|
||||
@@ -1064,9 +1064,6 @@ 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,7 @@ export enum ApiErrorCode {
|
||||
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
||||
Unauthorized = 'UNAUTHORIZED',
|
||||
Unknown = 'UNKNOWN',
|
||||
InvalidPin = 'INVALID_PIN',
|
||||
NewPlexLoginDisabled = 'NEW_PLEX_LOGIN_DISABLED',
|
||||
ProfileUserExists = 'PROFILE_USER_EXISTS',
|
||||
}
|
||||
|
||||
@@ -91,6 +91,15 @@ 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;
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ 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';
|
||||
@@ -81,14 +80,6 @@ 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);
|
||||
|
||||
@@ -61,39 +61,9 @@ export interface CacheItem {
|
||||
};
|
||||
}
|
||||
|
||||
export interface DNSAddresses {
|
||||
ipv4: number;
|
||||
ipv6: number;
|
||||
}
|
||||
|
||||
export interface DNSRecord {
|
||||
addresses: DNSAddresses;
|
||||
activeAddress: string;
|
||||
family: number;
|
||||
age: number;
|
||||
ttl: number;
|
||||
networkErrors: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
}
|
||||
|
||||
export interface DNSStats {
|
||||
size: number;
|
||||
maxSize: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
failures: number;
|
||||
ipv4Fallbacks: number;
|
||||
hitRate: number;
|
||||
}
|
||||
|
||||
export interface CacheResponse {
|
||||
apiCaches: CacheItem[];
|
||||
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
|
||||
dnsCache: {
|
||||
entries: Record<string, DNSRecord>;
|
||||
stats: DNSStats;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
|
||||
@@ -72,7 +72,6 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
|
||||
const blacklistedTagsArr = blacklistedTags.split(',');
|
||||
|
||||
const pageLimit = settings.main.blacklistedTagsLimit;
|
||||
const invalidKeywords = new Set<string>();
|
||||
|
||||
if (blacklistedTags.length === 0) {
|
||||
return;
|
||||
@@ -88,19 +87,6 @@ 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
|
||||
|
||||
@@ -116,51 +102,24 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
|
||||
throw new AbortTransaction();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getDiscover({
|
||||
page,
|
||||
sortBy,
|
||||
keywords: tag,
|
||||
});
|
||||
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));
|
||||
|
||||
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,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -100,6 +100,17 @@ 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;
|
||||
@@ -127,29 +138,11 @@ 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 {
|
||||
@@ -549,11 +542,6 @@ class Settings {
|
||||
bypassFilter: '',
|
||||
bypassLocalAddresses: true,
|
||||
},
|
||||
dnsCache: {
|
||||
enabled: false,
|
||||
forceMinTtl: 0,
|
||||
forceMaxTtl: -1,
|
||||
},
|
||||
},
|
||||
};
|
||||
if (initialSettings) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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"`);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ 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) => {
|
||||
@@ -49,7 +48,12 @@ 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 };
|
||||
const body = req.body as {
|
||||
authToken?: string;
|
||||
profileId?: string;
|
||||
pin?: string;
|
||||
isSetup?: boolean;
|
||||
};
|
||||
|
||||
if (!body.authToken) {
|
||||
return next({
|
||||
@@ -65,12 +69,97 @@ 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);
|
||||
|
||||
// Next let's see if the user already exists
|
||||
// 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
|
||||
let user = await userRepository
|
||||
.createQueryBuilder('user')
|
||||
.where('user.plexId = :id', { id: account.id })
|
||||
@@ -79,7 +168,40 @@ 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,
|
||||
@@ -88,6 +210,8 @@ 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;
|
||||
@@ -135,13 +259,15 @@ 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) {
|
||||
@@ -157,19 +283,11 @@ authRoutes.post('/plex', async (req, res, next) => {
|
||||
);
|
||||
return next({
|
||||
status: 403,
|
||||
error: ApiErrorCode.NewPlexLoginDisabled,
|
||||
message: 'Access denied.',
|
||||
});
|
||||
} else {
|
||||
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,
|
||||
}
|
||||
);
|
||||
// Create new user
|
||||
user = new User({
|
||||
email: account.email,
|
||||
plexUsername: account.username,
|
||||
@@ -178,13 +296,15 @@ 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.warn(
|
||||
'Failed sign-in attempt by Plex user without access to the media server',
|
||||
logger.info(
|
||||
'Sign-in attempt from Plex user with access to the media server; creating new Jellyseerr user',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
@@ -195,17 +315,62 @@ authRoutes.post('/plex', async (req, res, next) => {
|
||||
);
|
||||
return next({
|
||||
status: 403,
|
||||
error: ApiErrorCode.NewPlexLoginDisabled,
|
||||
message: 'Access denied.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set logged in session
|
||||
if (req.session) {
|
||||
req.session.userId = user.id;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json(user?.filter() ?? {});
|
||||
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() ?? {});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong authenticating with Plex account', {
|
||||
label: 'API',
|
||||
@@ -219,6 +384,364 @@ 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}`;
|
||||
}
|
||||
|
||||
@@ -128,15 +128,11 @@ discoverRoutes.get('/movies', async (req, res, next) => {
|
||||
if (keywords) {
|
||||
const splitKeywords = keywords.split(',');
|
||||
|
||||
const keywordResults = await Promise.all(
|
||||
keywordData = 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({
|
||||
@@ -419,15 +415,11 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
||||
if (keywords) {
|
||||
const splitKeywords = keywords.split(',');
|
||||
|
||||
const keywordResults = await Promise.all(
|
||||
keywordData = 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({
|
||||
|
||||
@@ -4,40 +4,27 @@ import { Router } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 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;
|
||||
}
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
router.get('/:type/*', async (req, res) => {
|
||||
const imagePath = req.path.replace(/^\/\w+/, '');
|
||||
try {
|
||||
let imageData;
|
||||
if (req.params.type === 'tmdb') {
|
||||
imageData = await initTmdbImageProxy().getImage(imagePath);
|
||||
imageData = await tmdbImageProxy.getImage(imagePath);
|
||||
} else if (req.params.type === 'tvdb') {
|
||||
imageData = await initTvdbImageProxy().getImage(imagePath);
|
||||
imageData = await tvdbImageProxy.getImage(imagePath);
|
||||
} else {
|
||||
logger.error('Unsupported image type', {
|
||||
imagePath,
|
||||
|
||||
@@ -197,10 +197,8 @@ mediaRoutes.delete(
|
||||
const media = await mediaRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.id) },
|
||||
});
|
||||
|
||||
const is4k = req.query.is4k === 'true';
|
||||
const is4k = media.serviceUrl4k !== undefined;
|
||||
const isMovie = media.mediaType === MediaType.MOVIE;
|
||||
|
||||
let serviceSettings;
|
||||
if (isMovie) {
|
||||
serviceSettings = settings.radarr.find(
|
||||
@@ -227,7 +225,6 @@ mediaRoutes.delete(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!serviceSettings) {
|
||||
logger.warn(
|
||||
`There is no default ${
|
||||
@@ -242,7 +239,6 @@ mediaRoutes.delete(
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let service;
|
||||
if (isMovie) {
|
||||
service = new RadarrAPI({
|
||||
|
||||
@@ -28,7 +28,6 @@ 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 { Router } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
@@ -472,13 +471,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);
|
||||
@@ -504,7 +503,7 @@ settingsRoutes.get(
|
||||
plexUsers.map(async (plexUser) => {
|
||||
if (
|
||||
!existingUsers.find(
|
||||
(user) =>
|
||||
(user: User) =>
|
||||
user.plexId === parseInt(plexUser.id) ||
|
||||
user.email === plexUser.email.toLowerCase()
|
||||
) &&
|
||||
@@ -514,16 +513,36 @@ settingsRoutes.get(
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return res.status(200).json(sortBy(unimportedPlexUsers, 'username'));
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong getting unimported Plex users', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
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,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Something went wrong getting unimported Plex users and profiles',
|
||||
{
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
}
|
||||
);
|
||||
next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve unimported Plex users.',
|
||||
message: 'Unable to retrieve unimported Plex users and profiles.',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -756,19 +775,12 @@ settingsRoutes.get('/cache', async (_req, res) => {
|
||||
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
|
||||
const avatarImageCache = await ImageProxy.getImageStats('avatar');
|
||||
|
||||
const stats = dnsCache?.getStats();
|
||||
const entries = dnsCache?.getCacheEntries();
|
||||
|
||||
return res.status(200).json({
|
||||
apiCaches,
|
||||
imageCache: {
|
||||
tmdb: tmdbImageCache,
|
||||
avatar: avatarImageCache,
|
||||
},
|
||||
dnsCache: {
|
||||
stats,
|
||||
entries,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -786,20 +798,6 @@ 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),
|
||||
|
||||
@@ -528,43 +528,80 @@ router.post(
|
||||
try {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
const body = req.body as { plexIds: string[] } | undefined;
|
||||
const { plexIds, profileIds } = req.body as {
|
||||
plexIds?: string[];
|
||||
profileIds?: string[];
|
||||
};
|
||||
|
||||
const skippedItems: {
|
||||
id: string;
|
||||
type: 'user' | 'profile';
|
||||
reason: string;
|
||||
}[] = [];
|
||||
const createdUsers: User[] = [];
|
||||
|
||||
// taken from auth.ts
|
||||
const mainUser = await userRepository.findOneOrFail({
|
||||
select: { id: true, plexToken: true },
|
||||
select: { id: true, plexToken: true, email: true, plexId: true },
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
|
||||
|
||||
const plexUsersResponse = await mainPlexTv.getUsers();
|
||||
const createdUsers: User[] = [];
|
||||
for (const rawUser of plexUsersResponse.MediaContainer.User) {
|
||||
const account = rawUser.$;
|
||||
if (plexIds && plexIds.length > 0) {
|
||||
const plexUsersResponse = await mainPlexTv.getUsers();
|
||||
|
||||
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();
|
||||
for (const rawUser of plexUsersResponse.MediaContainer.User) {
|
||||
const account = rawUser.$;
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -574,6 +611,7 @@ router.post(
|
||||
avatar: account.thumb,
|
||||
userType: UserType.PLEX,
|
||||
});
|
||||
|
||||
await userRepository.save(newUser);
|
||||
createdUsers.push(newUser);
|
||||
}
|
||||
@@ -581,7 +619,89 @@ router.post(
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(201).json(User.filterMany(createdUsers));
|
||||
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,
|
||||
});
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
|
||||
@@ -33,93 +33,52 @@ import { EventSubscriber } from 'typeorm';
|
||||
export class MediaRequestSubscriber
|
||||
implements EntitySubscriberInterface<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
|
||||
private async notifyAvailableMovie(entity: MediaRequest) {
|
||||
if (
|
||||
!latestMedia ||
|
||||
latestMedia[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE
|
||||
entity.media[entity.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.AVAILABLE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
try {
|
||||
const movie = await tmdb.getMovie({
|
||||
movieId: entity.media.tmdbId,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
const requestedSeasons =
|
||||
entity.seasons?.map((entitySeason) => entitySeason.seasonNumber) ?? [];
|
||||
const availableSeasons = latestMedia.seasons.filter(
|
||||
const availableSeasons = entity.media.seasons.filter(
|
||||
(season) =>
|
||||
season[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE &&
|
||||
requestedSeasons.includes(season.seasonNumber)
|
||||
@@ -128,46 +87,44 @@ export class MediaRequestSubscriber
|
||||
availableSeasons.length > 0 &&
|
||||
availableSeasons.length === requestedSeasons.length;
|
||||
|
||||
if (!isMediaAvailable) {
|
||||
return;
|
||||
}
|
||||
if (isMediaAvailable) {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
try {
|
||||
const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -825,10 +782,10 @@ export class MediaRequestSubscriber
|
||||
|
||||
if (event.entity.status === MediaRequestStatus.COMPLETED) {
|
||||
if (event.entity.media.mediaType === MediaType.MOVIE) {
|
||||
this.notifyAvailableMovie(event.entity as MediaRequest, event);
|
||||
this.notifyAvailableMovie(event.entity as MediaRequest);
|
||||
}
|
||||
if (event.entity.media.mediaType === MediaType.TV) {
|
||||
this.notifyAvailableSeries(event.entity as MediaRequest, event);
|
||||
this.notifyAvailableSeries(event.entity as MediaRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import logger from '@server/logger';
|
||||
import { DnsCacheManager } from 'dns-caching';
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
export default dnsCache;
|
||||
@@ -29,10 +29,14 @@ const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => {
|
||||
const keywordIds = data.blacklistedTags.slice(1, -1).split(',');
|
||||
Promise.all(
|
||||
keywordIds.map(async (keywordId) => {
|
||||
const { data } = await axios.get<Keyword | null>(
|
||||
`/api/v1/keyword/${keywordId}`
|
||||
);
|
||||
return data?.name || `[Invalid: ${keywordId}]`;
|
||||
try {
|
||||
const { data } = await axios.get<Keyword>(
|
||||
`/api/v1/keyword/${keywordId}`
|
||||
);
|
||||
return data.name;
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
})
|
||||
).then((keywords) => {
|
||||
setTagNamesBlacklistedFor(keywords.join(', '));
|
||||
|
||||
@@ -5,10 +5,7 @@ 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 {
|
||||
TmdbKeyword,
|
||||
TmdbKeywordSearchResponse,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type { TmdbKeywordSearchResponse } from '@server/api/themoviedb/interfaces';
|
||||
import type { Keyword } from '@server/models/common';
|
||||
import axios from 'axios';
|
||||
import { useFormikContext } from 'formik';
|
||||
@@ -127,19 +124,15 @@ const ControlledKeywordSelector = ({
|
||||
|
||||
const keywords = await Promise.all(
|
||||
defaultValue.split(',').map(async (keywordId) => {
|
||||
const { data } = await axios.get<Keyword | null>(
|
||||
const { data } = await axios.get<Keyword>(
|
||||
`/api/v1/keyword/${keywordId}`
|
||||
);
|
||||
return data;
|
||||
})
|
||||
);
|
||||
|
||||
const validKeywords: TmdbKeyword[] = keywords.filter(
|
||||
(keyword): keyword is TmdbKeyword => keyword !== null
|
||||
);
|
||||
|
||||
onChange(
|
||||
validKeywords.map((keyword) => ({
|
||||
keywords.map((keyword) => ({
|
||||
label: keyword.name,
|
||||
value: keyword.id,
|
||||
}))
|
||||
|
||||
@@ -77,19 +77,16 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
|
||||
const keywords = await Promise.all(
|
||||
slider.data.split(',').map(async (keywordId) => {
|
||||
const keyword = await axios.get<Keyword | null>(
|
||||
const keyword = await axios.get<Keyword>(
|
||||
`/api/v1/keyword/${keywordId}`
|
||||
);
|
||||
|
||||
return keyword.data;
|
||||
})
|
||||
);
|
||||
|
||||
const validKeywords: Keyword[] = keywords.filter(
|
||||
(keyword): keyword is Keyword => keyword !== null
|
||||
);
|
||||
|
||||
setDefaultDataValue(
|
||||
validKeywords.map((keyword) => ({
|
||||
keywords.map((keyword) => ({
|
||||
label: keyword.name,
|
||||
value: keyword.id,
|
||||
}))
|
||||
|
||||
195
src/components/Login/PlexPinEntry.tsx
Normal file
195
src/components/Login/PlexPinEntry.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
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;
|
||||
170
src/components/Login/PlexProfileSelector.tsx
Normal file
170
src/components/Login/PlexProfileSelector.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
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;
|
||||
@@ -8,11 +8,15 @@ 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';
|
||||
@@ -29,6 +33,11 @@ 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 = () => {
|
||||
@@ -39,36 +48,158 @@ const Login = () => {
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [isProcessing, setProcessing] = useState(false);
|
||||
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
|
||||
const [authToken, setAuthToken] = useState<string | 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 });
|
||||
|
||||
if (response.data?.id) {
|
||||
revalidate();
|
||||
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;
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.response?.data?.message);
|
||||
const httpStatus = e?.response?.status;
|
||||
const msg =
|
||||
httpStatus === 403
|
||||
? intl.formatMessage(messages.accessDenied)
|
||||
: e?.response?.data?.message ??
|
||||
intl.formatMessage(messages.authFailed);
|
||||
setError(msg);
|
||||
setAuthToken(undefined);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
if (authToken) {
|
||||
login();
|
||||
}
|
||||
}, [authToken, revalidate]);
|
||||
}, [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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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('/');
|
||||
@@ -197,48 +328,85 @@ const Login = () => {
|
||||
</div>
|
||||
</Transition>
|
||||
<div className="px-10 py-8">
|
||||
<SwitchTransition mode="out-in">
|
||||
<CSSTransition
|
||||
key={mediaServerLogin ? 'ms' : 'local'}
|
||||
nodeRef={loginRef}
|
||||
addEndListener={(done) => {
|
||||
loginRef.current?.addEventListener(
|
||||
'transitionend',
|
||||
done,
|
||||
false
|
||||
);
|
||||
{showPinEntry && pinProfileId && pinProfileName ? (
|
||||
<PlexPinEntry
|
||||
profileId={pinProfileId}
|
||||
profileName={pinProfileName}
|
||||
profileThumb={pinProfileThumb}
|
||||
isProtected={pinIsProtected}
|
||||
isMainUser={pinIsMainUser}
|
||||
error={pinError}
|
||||
onSubmit={(pin) => {
|
||||
return handleSubmitProfile(pinProfileId, pin);
|
||||
}}
|
||||
onEntered={() => {
|
||||
document
|
||||
.querySelector<HTMLInputElement>('#email, #username')
|
||||
?.focus();
|
||||
onCancel={() => {
|
||||
setShowPinEntry(false);
|
||||
setPinProfileId(null);
|
||||
setPinProfileName(null);
|
||||
setPinProfileThumb(null);
|
||||
setPinIsProtected(false);
|
||||
setPinIsMainUser(false);
|
||||
setPinError(null);
|
||||
setShowProfileSelector(true);
|
||||
}}
|
||||
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 ? (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{additionalLoginOptions.length > 0 &&
|
||||
{!showProfileSelector &&
|
||||
!showPinEntry &&
|
||||
additionalLoginOptions.length > 0 &&
|
||||
(loginFormVisible ? (
|
||||
<div className="flex items-center py-5">
|
||||
<div className="flex-grow border-t border-gray-600"></div>
|
||||
@@ -253,13 +421,15 @@ const Login = () => {
|
||||
</h2>
|
||||
))}
|
||||
|
||||
<div
|
||||
className={`flex w-full flex-wrap gap-2 ${
|
||||
!loginFormVisible ? 'flex-col' : ''
|
||||
}`}
|
||||
>
|
||||
{additionalLoginOptions}
|
||||
</div>
|
||||
{!showProfileSelector && !showPinEntry && (
|
||||
<div
|
||||
className={`flex w-full flex-wrap gap-2 ${
|
||||
!loginFormVisible ? 'flex-col' : ''
|
||||
}`}
|
||||
>
|
||||
{additionalLoginOptions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
|
||||
@@ -118,11 +118,9 @@ const ManageSlideOver = ({
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMediaFile = async (is4k = false) => {
|
||||
const deleteMediaFile = async () => {
|
||||
if (data.mediaInfo) {
|
||||
await axios.delete(
|
||||
`/api/v1/media/${data.mediaInfo.id}/file?is4k=${is4k}`
|
||||
);
|
||||
await axios.delete(`/api/v1/media/${data.mediaInfo.id}/file`);
|
||||
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
|
||||
revalidate();
|
||||
onClose();
|
||||
@@ -416,7 +414,7 @@ const ManageSlideOver = ({
|
||||
isDefaultService() && (
|
||||
<div>
|
||||
<ConfirmButton
|
||||
onClick={() => deleteMediaFile(false)}
|
||||
onClick={() => deleteMediaFile()}
|
||||
confirmText={intl.formatMessage(
|
||||
globalMessages.areyousure
|
||||
)}
|
||||
@@ -575,7 +573,7 @@ const ManageSlideOver = ({
|
||||
{isDefaultService() && (
|
||||
<div>
|
||||
<ConfirmButton
|
||||
onClick={() => deleteMediaFile(true)}
|
||||
onClick={() => deleteMediaFile()}
|
||||
confirmText={intl.formatMessage(
|
||||
globalMessages.areyousure
|
||||
)}
|
||||
|
||||
@@ -343,9 +343,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
|
||||
const deleteMediaFile = async () => {
|
||||
if (request.media) {
|
||||
await axios.delete(
|
||||
`/api/v1/media/${request.media.id}/file?is4k=${request.is4k}`
|
||||
);
|
||||
await axios.delete(`/api/v1/media/${request.media.id}/file`);
|
||||
await axios.delete(`/api/v1/media/${request.media.id}`);
|
||||
revalidateList();
|
||||
}
|
||||
|
||||
@@ -309,19 +309,16 @@ export const KeywordSelector = ({
|
||||
|
||||
const keywords = await Promise.all(
|
||||
defaultValue.split(',').map(async (keywordId) => {
|
||||
const keyword = await axios.get<Keyword | null>(
|
||||
const keyword = await axios.get<Keyword>(
|
||||
`/api/v1/keyword/${keywordId}`
|
||||
);
|
||||
|
||||
return keyword.data;
|
||||
})
|
||||
);
|
||||
|
||||
const validKeywords: Keyword[] = keywords.filter(
|
||||
(keyword): keyword is Keyword => keyword !== null
|
||||
);
|
||||
|
||||
setDefaultDataValue(
|
||||
validKeywords.map((keyword) => ({
|
||||
keywords.map((keyword) => ({
|
||||
label: keyword.name,
|
||||
value: keyword.id,
|
||||
}))
|
||||
|
||||
@@ -113,16 +113,12 @@ const OverrideRuleTiles = ({
|
||||
.flat()
|
||||
.filter((keywordId) => keywordId)
|
||||
.map(async (keywordId) => {
|
||||
const response = await axios.get<Keyword | null>(
|
||||
`/api/v1/keyword/${keywordId}`
|
||||
);
|
||||
return response.data;
|
||||
const response = await axios.get(`/api/v1/keyword/${keywordId}`);
|
||||
const keyword: Keyword = response.data;
|
||||
return keyword;
|
||||
})
|
||||
);
|
||||
const validKeywords: Keyword[] = keywords.filter(
|
||||
(keyword): keyword is Keyword => keyword !== null
|
||||
);
|
||||
setKeywords(validKeywords);
|
||||
setKeywords(keywords);
|
||||
const allUsersFromRules = rules
|
||||
.map((rule) => rule.users)
|
||||
.filter((users) => users)
|
||||
|
||||
@@ -22,7 +22,6 @@ 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';
|
||||
@@ -56,26 +55,6 @@ 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',
|
||||
dnscachenetworkerrors: 'Network Errors',
|
||||
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',
|
||||
@@ -263,18 +242,6 @@ 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', '*', '*', '*', '*'];
|
||||
|
||||
@@ -318,18 +285,6 @@ const SettingsJobs = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatAge = (milliseconds: number): string => {
|
||||
const duration = intervalToDuration({
|
||||
start: 0,
|
||||
end: milliseconds,
|
||||
});
|
||||
|
||||
return formatDuration(duration, {
|
||||
format: ['minutes', 'seconds'],
|
||||
zero: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
@@ -612,95 +567,6 @@ 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>
|
||||
{intl.formatMessage(messages.dnscachenetworkerrors)}
|
||||
</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>{intl.formatNumber(data.networkErrors)}</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">
|
||||
|
||||
@@ -45,13 +45,6 @@ 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 = () => {
|
||||
@@ -97,9 +90,6 @@ 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,
|
||||
@@ -118,11 +108,6 @@ 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,
|
||||
@@ -236,90 +221,6 @@ 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">
|
||||
|
||||
@@ -34,22 +34,37 @@ 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 () => {
|
||||
const response = await axios.post('/api/v1/auth/plex', {
|
||||
authToken: authToken,
|
||||
});
|
||||
if (!authToken) return;
|
||||
|
||||
if (response.data?.email) {
|
||||
revalidate();
|
||||
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 (authToken && mediaServerType == MediaServerType.PLEX) {
|
||||
|
||||
if (authToken && mediaServerType === MediaServerType.PLEX) {
|
||||
login();
|
||||
}
|
||||
}, [authToken, mediaServerType, revalidate]);
|
||||
@@ -58,7 +73,7 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
|
||||
if (user) {
|
||||
onComplete();
|
||||
}
|
||||
}, [user, mediaServerType, onComplete]);
|
||||
}, [user, onComplete]);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
@@ -74,14 +89,20 @@ 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={(authToken) => {
|
||||
onAuthToken={(token) => {
|
||||
setMediaServerType(MediaServerType.PLEX);
|
||||
setAuthToken(authToken);
|
||||
setAuthToken(token);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
@@ -16,14 +16,31 @@ interface PlexImportProps {
|
||||
}
|
||||
|
||||
const messages = defineMessages('components.UserList', {
|
||||
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!',
|
||||
importfromplex: 'Import Plex Users & Profiles',
|
||||
importfromplexerror:
|
||||
'Something went wrong while importing Plex users and profiles.',
|
||||
user: 'User',
|
||||
nouserstoimport: 'There are no Plex users to import.',
|
||||
profile: 'Profile',
|
||||
nouserstoimport: 'There are no Plex users or profiles 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) => {
|
||||
@@ -32,44 +49,148 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
||||
const { addToast } = useToasts();
|
||||
const [isImporting, setImporting] = useState(false);
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||
const { data, error } = useSWR<
|
||||
{
|
||||
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
|
||||
const [duplicateMap, setDuplicateMap] = useState<{
|
||||
[key: string]: { type: 'user' | 'profile'; duplicateWith: string[] };
|
||||
}>({});
|
||||
|
||||
const { data, error } = useSWR<{
|
||||
users: {
|
||||
id: string;
|
||||
title: string;
|
||||
username: string;
|
||||
email: string;
|
||||
thumb: string;
|
||||
}[]
|
||||
>(`/api/v1/settings/plex/users`, {
|
||||
}[];
|
||||
profiles: {
|
||||
id: string;
|
||||
title: string;
|
||||
username?: string;
|
||||
thumb: string;
|
||||
isMainUser?: boolean;
|
||||
protected?: boolean;
|
||||
}[];
|
||||
}>('/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: createdUsers } = await axios.post(
|
||||
const { data: response } = 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>,
|
||||
}),
|
||||
{
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
plexIds: selectedUsers,
|
||||
profileIds: selectedProfiles,
|
||||
}
|
||||
);
|
||||
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
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');
|
||||
}
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.importfromplexerror), {
|
||||
@@ -84,24 +205,116 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
||||
const isSelectedUser = (plexId: string): boolean =>
|
||||
selectedUsers.includes(plexId);
|
||||
|
||||
const isAllUsers = (): boolean => selectedUsers.length === data?.length;
|
||||
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 toggleUser = (plexId: string): void => {
|
||||
if (selectedUsers.includes(plexId)) {
|
||||
setSelectedUsers((users) => users.filter((user) => user !== plexId));
|
||||
setSelectedUsers((users: string[]) =>
|
||||
users.filter((user: string) => user !== plexId)
|
||||
);
|
||||
} else {
|
||||
setSelectedUsers((users) => [...users, plexId]);
|
||||
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]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAllUsers = (): void => {
|
||||
if (data && selectedUsers.length >= 0 && !isAllUsers()) {
|
||||
setSelectedUsers(data.map((user) => user.id));
|
||||
if (data?.users && data.users.length > 0 && !isAllUsers()) {
|
||||
setSelectedUsers(data.users.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}
|
||||
@@ -109,13 +322,13 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
||||
onOk={() => {
|
||||
importUsers();
|
||||
}}
|
||||
okDisabled={isImporting || !selectedUsers.length}
|
||||
okDisabled={isImporting || !hasSelectedContent}
|
||||
okText={intl.formatMessage(
|
||||
isImporting ? globalMessages.importing : globalMessages.import
|
||||
)}
|
||||
onCancel={onCancel}
|
||||
>
|
||||
{data?.length ? (
|
||||
{hasImportableContent ? (
|
||||
<>
|
||||
{settings.currentSettings.newPlexLogin && (
|
||||
<Alert
|
||||
@@ -127,57 +340,26 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
||||
type="info"
|
||||
/>
|
||||
)}
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
|
||||
{/* 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={isSelectedUser(user.id)}
|
||||
onClick={() => toggleUser(user.id)}
|
||||
aria-checked={isAllUsers()}
|
||||
onClick={() => toggleAllUsers()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === 'Space') {
|
||||
toggleUser(user.id);
|
||||
toggleAllUsers();
|
||||
}
|
||||
}}
|
||||
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
|
||||
@@ -185,7 +367,132 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isSelectedUser(user.id)
|
||||
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'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<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
|
||||
role="checkbox"
|
||||
tabIndex={0}
|
||||
aria-checked={isAllProfiles()}
|
||||
onClick={() => toggleAllProfiles()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === 'Space') {
|
||||
toggleAllProfiles();
|
||||
}
|
||||
}}
|
||||
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={`${
|
||||
isAllProfiles()
|
||||
? 'bg-indigo-500'
|
||||
: 'bg-gray-800'
|
||||
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
||||
@@ -193,44 +500,96 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isSelectedUser(user.id)
|
||||
isAllProfiles()
|
||||
? '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="text-base font-bold leading-5">
|
||||
{user.username}
|
||||
</div>
|
||||
{user.username &&
|
||||
user.username.toLowerCase() !==
|
||||
user.email && (
|
||||
</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 && (
|
||||
<div className="text-sm leading-5 text-gray-300">
|
||||
{user.email}
|
||||
(PIN protected)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Alert
|
||||
|
||||
@@ -237,7 +237,20 @@
|
||||
"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.",
|
||||
@@ -248,6 +261,7 @@
|
||||
"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}",
|
||||
@@ -255,6 +269,7 @@
|
||||
"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",
|
||||
@@ -873,17 +888,6 @@
|
||||
"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.dnscachenetworkerrors": "Network Errors",
|
||||
"components.Settings.SettingsJobsCache.download-sync": "Download Sync",
|
||||
"components.Settings.SettingsJobsCache.download-sync-reset": "Download Sync Reset",
|
||||
"components.Settings.SettingsJobsCache.editJobSchedule": "Modify Job",
|
||||
@@ -893,17 +897,12 @@
|
||||
"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.",
|
||||
@@ -915,7 +914,6 @@
|
||||
"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",
|
||||
@@ -925,7 +923,6 @@
|
||||
"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",
|
||||
@@ -987,9 +984,6 @@
|
||||
"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.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",
|
||||
@@ -1302,27 +1296,36 @@
|
||||
"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",
|
||||
"components.UserList.importfromplexerror": "Something went wrong while importing Plex users.",
|
||||
"components.UserList.importfromplex": "Import Plex Users & Profiles",
|
||||
"components.UserList.importfromplexerror": "Something went wrong while importing Plex users and profiles.",
|
||||
"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 to import.",
|
||||
"components.UserList.nouserstoimport": "There are no Plex users or profiles 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",
|
||||
|
||||
Reference in New Issue
Block a user