mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
Compare commits
19 Commits
Fallenbage
...
fallenbage
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02755f03d | ||
|
|
f2c771727c | ||
|
|
87b51b809b | ||
|
|
43553cb2d5 | ||
|
|
8bb7d4e380 | ||
|
|
8c4e39d098 | ||
|
|
973e43f1cc | ||
|
|
a93716eb15 | ||
|
|
6000c36c69 | ||
|
|
6c9aaf9777 | ||
|
|
c4d06540a6 | ||
|
|
98a6075cb6 | ||
|
|
15356dfe49 | ||
|
|
1f04eeb040 | ||
|
|
e3028c21f2 | ||
|
|
9d8b343790 | ||
|
|
f4fe16608a | ||
|
|
d660a540da | ||
|
|
48ef2984e5 |
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -3,7 +3,7 @@
|
||||
your pull request. Please fill in each section below to help us better prioritize your pull request. Thanks!
|
||||
-->
|
||||
|
||||
#### Description
|
||||
## Description
|
||||
|
||||
<!--- Describe your changes in detail -->
|
||||
<!--- Why is this change required? What problem does it solve? -->
|
||||
@@ -11,15 +11,15 @@
|
||||
|
||||
- Fixes #XXXX
|
||||
|
||||
#### How Has This Been Tested?
|
||||
## How Has This Been Tested?
|
||||
|
||||
<!--- Please describe in detail how you tested your changes. -->
|
||||
<!--- Include details of your testing environment, and the tests you ran to -->
|
||||
<!--- see how your change affects other areas of the code, etc. -->
|
||||
|
||||
#### Screenshots / Logs (if applicable)
|
||||
## Screenshots / Logs (if applicable)
|
||||
|
||||
#### Checklist:
|
||||
## Checklist:
|
||||
|
||||
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
|
||||
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Seerr helm chart for Kubernetes
|
||||
type: application
|
||||
version: 3.0.0
|
||||
# renovate: image=ghcr.io/seerr-team/seerr
|
||||
appVersion: '2.7.3'
|
||||
appVersion: '3.0.0'
|
||||
maintainers:
|
||||
- name: Seerr Team
|
||||
url: https://github.com/orgs/seerr-team/people
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# seerr-chart
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
Seerr helm chart for Kubernetes
|
||||
|
||||
@@ -22,7 +22,7 @@ Kubernetes: `>=1.23.0-0`
|
||||
|
||||
## Installation
|
||||
|
||||
Refer to [https://docs.seerr.dev/getting-started/kubernetes](Seerr kubernetes documentation)
|
||||
Refer to [Seerr kubernetes documentation](https://docs.seerr.dev/getting-started/kubernetes)
|
||||
|
||||
## Update Notes
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
## Installation
|
||||
|
||||
Refer to [https://docs.seerr.dev/getting-started/kubernetes](Seerr kubernetes documentation)
|
||||
Refer to [Seerr kubernetes documentation](https://docs.seerr.dev/getting-started/kubernetes)
|
||||
|
||||
## Update Notes
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Changes :
|
||||
|
||||
If you're migrating from a previous installation, you may need to update the ownership of your config folder:
|
||||
```bash
|
||||
sudo chown -R 1000:1000 /path/to/appdata/config
|
||||
docker run --rm -v /path/to/appdata/config:/data alpine chown -R 1000:1000 /data
|
||||
```
|
||||
|
||||
This ensures the `node` user (UID 1000) owns the config directory and can read and write to it.
|
||||
|
||||
@@ -174,4 +174,36 @@ This can happen if you have a new installation of Jellyfin/Emby or if you have c
|
||||
|
||||
This process should restore your admin privileges while preserving your settings.
|
||||
|
||||
## Failed to enable web push notifications
|
||||
|
||||
### Option 1: You are using Pi-hole
|
||||
|
||||
When using Pi-hole, you need to whitelist the proper domains in order for the queries to not be intercepted and blocked by Pi-hole.
|
||||
If you are using a chromium based browser (eg: Chrome, Brave, Edge...), the domain you need to whitelist is `fcm.googleapis.com`
|
||||
If you are using Firefox, the domain you need to whitelist is `push.services.mozilla.com`
|
||||
|
||||
1. Log into your Pi-hole through the admin interface, then click on Domains situated under GROUP MANAGEMENT.
|
||||
2. Add the domain corresponding to your browser in the `Domain to be added` field and then click on Add to allowed domains.
|
||||
3. Now in order for those changes to be used you need to flush your current dns cache.
|
||||
4. You can do so by using this command line in your Pi-hole terminal:
|
||||
```bash
|
||||
pihole restartdns
|
||||
```
|
||||
If this command fails (which is unlikely), use this equivalent:
|
||||
```bash
|
||||
pihole -f && pihole restartdns
|
||||
```
|
||||
5. Then restart your Seerr instance and try to enable the web push notifications again.
|
||||
|
||||
|
||||
### Option 2: You are using Brave browser
|
||||
|
||||
Brave is a "De-Googled" browser. So by default or if you refused a prompt in the past, it cuts the access to the FCM (Firebase Cloud Messaging) service, which is mandatory for the web push notifications on Chromium based browsers.
|
||||
|
||||
1. Open Brave and paste this address in the url bar: `brave://settings/privacy`
|
||||
2. Look for the option: "Use Google services for push messaging"
|
||||
3. Activate this option
|
||||
4. Relaunch Brave completely
|
||||
5. You should now see the notifications prompt appearing instead of an error message.
|
||||
|
||||
If you still encounter issues, please reach out on our support channels.
|
||||
|
||||
100
package.json
100
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "seerr",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"postinstall": "node postinstall-win.js",
|
||||
@@ -33,38 +33,38 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dr.pogodin/csurf": "^1.14.1",
|
||||
"@formatjs/intl-displaynames": "6.2.6",
|
||||
"@dr.pogodin/csurf": "^1.16.6",
|
||||
"@formatjs/intl-displaynames": "6.8.13",
|
||||
"@formatjs/intl-locale": "3.1.1",
|
||||
"@formatjs/intl-pluralrules": "5.1.10",
|
||||
"@formatjs/intl-pluralrules": "5.4.6",
|
||||
"@formatjs/intl-utils": "3.8.4",
|
||||
"@formatjs/swc-plugin-experimental": "^0.4.0",
|
||||
"@headlessui/react": "1.7.12",
|
||||
"@heroicons/react": "2.0.16",
|
||||
"@heroicons/react": "2.2.0",
|
||||
"@supercharge/request-ip": "1.2.0",
|
||||
"@svgr/webpack": "6.5.1",
|
||||
"@tanem/react-nprogress": "5.0.30",
|
||||
"@tanem/react-nprogress": "5.0.56",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@types/wink-jaro-distance": "^2.0.2",
|
||||
"ace-builds": "1.15.2",
|
||||
"axios": "1.10.0",
|
||||
"axios-rate-limit": "1.3.0",
|
||||
"ace-builds": "1.43.4",
|
||||
"axios": "1.13.2",
|
||||
"axios-rate-limit": "1.4.0",
|
||||
"bcrypt": "5.1.0",
|
||||
"bowser": "2.11.0",
|
||||
"bowser": "2.13.1",
|
||||
"connect-typeorm": "1.1.4",
|
||||
"cookie-parser": "1.4.7",
|
||||
"copy-to-clipboard": "3.3.3",
|
||||
"country-flag-icons": "1.5.5",
|
||||
"country-flag-icons": "1.6.4",
|
||||
"cronstrue": "2.23.0",
|
||||
"date-fns": "2.29.3",
|
||||
"dayjs": "1.11.7",
|
||||
"dayjs": "1.11.19",
|
||||
"dns-caching": "^0.2.7",
|
||||
"email-templates": "12.0.1",
|
||||
"email-templates": "12.0.3",
|
||||
"express": "4.21.2",
|
||||
"express-openapi-validator": "4.13.8",
|
||||
"express-rate-limit": "6.7.0",
|
||||
"express-session": "1.17.3",
|
||||
"formik": "^2.4.6",
|
||||
"express-session": "1.18.2",
|
||||
"formik": "^2.4.9",
|
||||
"gravatar-url": "3.1.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
@@ -76,19 +76,19 @@
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.10.0",
|
||||
"openpgp": "5.11.2",
|
||||
"pg": "8.11.0",
|
||||
"pg": "8.16.3",
|
||||
"plex-api": "5.3.2",
|
||||
"pug": "3.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-ace": "10.1.0",
|
||||
"react-animate-height": "2.1.2",
|
||||
"react-aria": "3.23.0",
|
||||
"react-aria": "3.44.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-intersection-observer": "9.4.3",
|
||||
"react-intl": "^6.6.8",
|
||||
"react-markdown": "8.0.5",
|
||||
"react-popper-tooltip": "4.4.2",
|
||||
"react-select": "5.7.0",
|
||||
"react-select": "5.10.2",
|
||||
"react-spring": "9.7.1",
|
||||
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||
"react-toast-notifications": "2.5.1",
|
||||
@@ -97,19 +97,19 @@
|
||||
"react-use-clipboard": "1.0.9",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"secure-random-password": "0.2.3",
|
||||
"semver": "7.7.1",
|
||||
"semver": "7.7.3",
|
||||
"sharp": "^0.33.4",
|
||||
"sqlite3": "5.1.7",
|
||||
"swagger-ui-express": "4.6.2",
|
||||
"swr": "2.2.5",
|
||||
"swr": "2.3.7",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"typeorm": "0.3.12",
|
||||
"ua-parser-js": "^1.0.35",
|
||||
"undici": "^7.3.0",
|
||||
"validator": "^13.15.15",
|
||||
"web-push": "3.5.0",
|
||||
"undici": "^7.16.0",
|
||||
"validator": "^13.15.23",
|
||||
"web-push": "3.6.7",
|
||||
"wink-jaro-distance": "^2.0.0",
|
||||
"winston": "3.8.2",
|
||||
"winston": "3.18.3",
|
||||
"winston-daily-rotate-file": "4.7.1",
|
||||
"xml2js": "0.4.23",
|
||||
"yamljs": "0.3.0",
|
||||
@@ -123,32 +123,33 @@
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@types/bcrypt": "5.0.0",
|
||||
"@types/cookie-parser": "1.4.3",
|
||||
"@types/country-flag-icons": "1.2.0",
|
||||
"@types/csurf": "1.11.2",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/country-flag-icons": "1.2.2",
|
||||
"@types/csurf": "1.11.5",
|
||||
"@types/email-templates": "8.0.4",
|
||||
"@types/express": "4.17.17",
|
||||
"@types/express-session": "1.17.6",
|
||||
"@types/lodash": "4.14.191",
|
||||
"@types/express-session": "1.18.2",
|
||||
"@types/lodash": "4.17.21",
|
||||
"@types/mime": "3",
|
||||
"@types/node": "22.10.5",
|
||||
"@types/node-schedule": "2.1.0",
|
||||
"@types/node-schedule": "2.1.8",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-transition-group": "4.4.5",
|
||||
"@types/react-transition-group": "4.4.12",
|
||||
"@types/secure-random-password": "0.2.1",
|
||||
"@types/semver": "7.3.13",
|
||||
"@types/swagger-ui-express": "4.1.3",
|
||||
"@types/validator": "^13.15.3",
|
||||
"@types/web-push": "3.3.2",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/validator": "^13.15.10",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/xml2js": "0.4.11",
|
||||
"@types/yamljs": "0.2.31",
|
||||
"@types/yup": "0.29.14",
|
||||
"@typescript-eslint/eslint-plugin": "5.54.0",
|
||||
"@typescript-eslint/parser": "5.54.0",
|
||||
"autoprefixer": "10.4.13",
|
||||
"commitizen": "4.3.0",
|
||||
"autoprefixer": "10.4.22",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"commitizen": "4.3.1",
|
||||
"copyfiles": "2.4.1",
|
||||
"cy-mobile-commands": "0.3.0",
|
||||
"cypress": "14.1.0",
|
||||
@@ -157,22 +158,22 @@
|
||||
"eslint-config-next": "^14.2.4",
|
||||
"eslint-config-prettier": "8.6.0",
|
||||
"eslint-plugin-formatjs": "4.9.0",
|
||||
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||
"eslint-plugin-no-relative-import-paths": "1.5.2",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-no-relative-import-paths": "1.6.1",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"husky": "8.0.3",
|
||||
"lint-staged": "13.1.2",
|
||||
"nodemon": "3.1.9",
|
||||
"postcss": "8.4.31",
|
||||
"nodemon": "3.1.11",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "2.8.4",
|
||||
"prettier-plugin-organize-imports": "3.2.2",
|
||||
"prettier-plugin-tailwindcss": "0.2.3",
|
||||
"tailwindcss": "3.2.7",
|
||||
"ts-node": "10.9.1",
|
||||
"tsc-alias": "1.8.2",
|
||||
"tsconfig-paths": "4.1.2",
|
||||
"ts-node": "10.9.2",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "4.9.5"
|
||||
},
|
||||
"engines": {
|
||||
@@ -181,7 +182,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"sqlite3/node-gyp": "8.4.1",
|
||||
"@types/express-session": "1.17.6"
|
||||
"@types/express-session": "1.18.2"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
@@ -204,8 +205,11 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"sqlite3",
|
||||
"bcrypt"
|
||||
"@swc/core",
|
||||
"bcrypt",
|
||||
"cypress",
|
||||
"sharp",
|
||||
"sqlite3"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
4120
pnpm-lock.yaml
generated
4120
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
111
seerr-api.yml
111
seerr-api.yml
@@ -3984,6 +3984,85 @@ paths:
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
/auth/jellyfin/quickconnect/initiate:
|
||||
post:
|
||||
summary: Initiate Jellyfin Quick Connect
|
||||
description: Initiates a Quick Connect session and returns a code for the user to authorize on their Jellyfin server.
|
||||
security: []
|
||||
tags:
|
||||
- auth
|
||||
responses:
|
||||
'200':
|
||||
description: Quick Connect session initiated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: '123456'
|
||||
secret:
|
||||
type: string
|
||||
example: 'abc123def456'
|
||||
'500':
|
||||
description: Failed to initiate Quick Connect
|
||||
/auth/jellyfin/quickconnect/check:
|
||||
get:
|
||||
summary: Check Quick Connect authorization status
|
||||
description: Checks if the Quick Connect code has been authorized by the user.
|
||||
security: []
|
||||
tags:
|
||||
- auth
|
||||
parameters:
|
||||
- in: query
|
||||
name: secret
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The secret returned from the initiate endpoint
|
||||
responses:
|
||||
'200':
|
||||
description: Authorization status returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
authenticated:
|
||||
type: boolean
|
||||
example: false
|
||||
'404':
|
||||
description: Quick Connect session not found or expired
|
||||
/auth/jellyfin/quickconnect/authenticate:
|
||||
post:
|
||||
summary: Authenticate with Quick Connect
|
||||
description: Completes the Quick Connect authentication flow and creates a user session.
|
||||
security: []
|
||||
tags:
|
||||
- auth
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
secret:
|
||||
type: string
|
||||
required:
|
||||
- secret
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully authenticated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
'403':
|
||||
description: Quick Connect not authorized or access denied
|
||||
'500':
|
||||
description: Authentication failed
|
||||
/auth/local:
|
||||
post:
|
||||
summary: Sign in using a local account
|
||||
@@ -4913,6 +4992,38 @@ paths:
|
||||
description: Unlink request invalid
|
||||
'404':
|
||||
description: User does not exist
|
||||
/user/{userId}/settings/linked-accounts/jellyfin/quickconnect:
|
||||
post:
|
||||
summary: Link Jellyfin/Emby account with Quick Connect
|
||||
description: Links a Jellyfin/Emby account to the user's profile using Quick Connect authentication
|
||||
tags:
|
||||
- users
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
secret:
|
||||
type: string
|
||||
required:
|
||||
- secret
|
||||
responses:
|
||||
'204':
|
||||
description: Account successfully linked
|
||||
'401':
|
||||
description: Unauthorized
|
||||
'422':
|
||||
description: Account already linked
|
||||
'500':
|
||||
description: Server error
|
||||
/user/{userId}/settings/notifications:
|
||||
get:
|
||||
summary: Get notification settings for a user
|
||||
|
||||
@@ -44,6 +44,23 @@ export interface JellyfinLoginResponse {
|
||||
AccessToken: string;
|
||||
}
|
||||
|
||||
export interface QuickConnectInitiateResponse {
|
||||
Secret: string;
|
||||
Code: string;
|
||||
DateAdded: string;
|
||||
}
|
||||
|
||||
export interface QuickConnectStatusResponse {
|
||||
Authenticated: boolean;
|
||||
Secret: string;
|
||||
Code: string;
|
||||
DeviceId: string;
|
||||
DeviceName: string;
|
||||
AppName: string;
|
||||
AppVersion: string;
|
||||
DateAdded: string;
|
||||
}
|
||||
|
||||
export interface JellyfinUserListResponse {
|
||||
users: JellyfinUserResponse[];
|
||||
}
|
||||
@@ -112,6 +129,10 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
||||
DateCreated?: string;
|
||||
}
|
||||
|
||||
type EpisodeReturn<T> = T extends { includeMediaInfo: true }
|
||||
? JellyfinLibraryItemExtended[]
|
||||
: JellyfinLibraryItem[];
|
||||
|
||||
export interface JellyfinItemsReponse {
|
||||
Items: JellyfinLibraryItemExtended[];
|
||||
TotalRecordCount: number;
|
||||
@@ -145,7 +166,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'X-Emby-Authorization': authHeaderVal,
|
||||
Authorization: authHeaderVal,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
@@ -212,6 +233,62 @@ class JellyfinAPI extends ExternalAPI {
|
||||
}
|
||||
}
|
||||
|
||||
public async initiateQuickConnect(): Promise<QuickConnectInitiateResponse> {
|
||||
try {
|
||||
const response = await this.post<QuickConnectInitiateResponse>(
|
||||
'/QuickConnect/Initiate'
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while initiating Quick Connect: ${e.message}`,
|
||||
{ label: 'Jellyfin API', error: e.response?.status }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public async checkQuickConnect(
|
||||
secret: string
|
||||
): Promise<QuickConnectStatusResponse> {
|
||||
try {
|
||||
const response = await this.get<QuickConnectStatusResponse>(
|
||||
'/QuickConnect/Connect',
|
||||
{ params: { secret } }
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting Quick Connect status: ${e.message}`,
|
||||
{ label: 'Jellyfin API', error: e.response?.status }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public async authenticateQuickConnect(
|
||||
secret: string
|
||||
): Promise<JellyfinLoginResponse> {
|
||||
try {
|
||||
const response = await this.post<JellyfinLoginResponse>(
|
||||
'/Users/AuthenticateWithQuickConnect',
|
||||
{ Secret: secret }
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while authenticating with Quick Connect: ${e.message}`,
|
||||
{ label: 'Jellyfin API', error: e.response?.status }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public setUserId(userId: string): void {
|
||||
this.userId = userId;
|
||||
return;
|
||||
@@ -415,13 +492,22 @@ class JellyfinAPI extends ExternalAPI {
|
||||
}
|
||||
}
|
||||
|
||||
public async getEpisodes(
|
||||
public async getEpisodes<
|
||||
T extends { includeMediaInfo?: boolean } | undefined = undefined
|
||||
>(
|
||||
seriesID: string,
|
||||
seasonID: string
|
||||
): Promise<JellyfinLibraryItem[]> {
|
||||
seasonID: string,
|
||||
options?: T
|
||||
): Promise<EpisodeReturn<T>> {
|
||||
try {
|
||||
const episodeResponse = await this.get<any>(
|
||||
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
|
||||
`/Shows/${seriesID}/Episodes`,
|
||||
{
|
||||
params: {
|
||||
seasonId: seasonID,
|
||||
...(options?.includeMediaInfo && { fields: 'MediaSources' }),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return episodeResponse.Items.filter(
|
||||
|
||||
@@ -374,9 +374,10 @@ class JellyfinScanner {
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
const jellyfinSeasons = await this.jfClient.getSeasons(Id);
|
||||
|
||||
for (const season of seasons) {
|
||||
const JellyfinSeasons = await this.jfClient.getSeasons(Id);
|
||||
const matchedJellyfinSeason = JellyfinSeasons.find((md) => {
|
||||
const matchedJellyfinSeason = jellyfinSeasons.find((md) => {
|
||||
if (tvdbSeasonFromAnidb) {
|
||||
// In AniDB we don't have the concept of seasons,
|
||||
// we have multiple shows with only Season 1 (and sometimes a season with index 0 for specials).
|
||||
@@ -397,38 +398,52 @@ class JellyfinScanner {
|
||||
|
||||
// Check if we found the matching season and it has all the available episodes
|
||||
if (matchedJellyfinSeason) {
|
||||
// If we have a matched Jellyfin season, get its children metadata so we can check details
|
||||
const episodes = await this.jfClient.getEpisodes(
|
||||
Id,
|
||||
matchedJellyfinSeason.Id
|
||||
);
|
||||
|
||||
//Get count of episodes that are HD and 4K
|
||||
let totalStandard = 0;
|
||||
let total4k = 0;
|
||||
|
||||
//use for loop to make sure this loop _completes_ in full
|
||||
//before the next section
|
||||
for (const episode of episodes) {
|
||||
let episodeCount = 1;
|
||||
if (!this.enable4kShow) {
|
||||
const episodes = await this.jfClient.getEpisodes(
|
||||
Id,
|
||||
matchedJellyfinSeason.Id
|
||||
);
|
||||
|
||||
// count number of combined episodes
|
||||
if (
|
||||
episode.IndexNumber !== undefined &&
|
||||
episode.IndexNumberEnd !== undefined
|
||||
) {
|
||||
episodeCount =
|
||||
episode.IndexNumberEnd - episode.IndexNumber + 1;
|
||||
}
|
||||
for (const episode of episodes) {
|
||||
let episodeCount = 1;
|
||||
|
||||
// count number of combined episodes
|
||||
if (
|
||||
episode.IndexNumber !== undefined &&
|
||||
episode.IndexNumberEnd !== undefined
|
||||
) {
|
||||
episodeCount =
|
||||
episode.IndexNumberEnd - episode.IndexNumber + 1;
|
||||
}
|
||||
|
||||
if (!this.enable4kShow) {
|
||||
totalStandard += episodeCount;
|
||||
} else {
|
||||
const ExtendedEpisodeData = await this.jfClient.getItemData(
|
||||
episode.Id
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 4K detection enabled - request media info to check resolution
|
||||
const episodes = await this.jfClient.getEpisodes(
|
||||
Id,
|
||||
matchedJellyfinSeason.Id,
|
||||
{ includeMediaInfo: true }
|
||||
);
|
||||
|
||||
ExtendedEpisodeData?.MediaSources?.some((MediaSource) => {
|
||||
for (const episode of episodes) {
|
||||
let episodeCount = 1;
|
||||
|
||||
// count number of combined episodes
|
||||
if (
|
||||
episode.IndexNumber !== undefined &&
|
||||
episode.IndexNumberEnd !== undefined
|
||||
) {
|
||||
episodeCount =
|
||||
episode.IndexNumberEnd - episode.IndexNumber + 1;
|
||||
}
|
||||
|
||||
// MediaSources field is included in response when includeMediaInfo is true
|
||||
// We iterate all MediaSources to detect if episode has both standard AND 4K versions
|
||||
episode.MediaSources?.some((MediaSource) => {
|
||||
return MediaSource.MediaStreams.some((MediaStream) => {
|
||||
if (MediaStream.Type === 'Video') {
|
||||
if ((MediaStream.Width ?? 0) >= 2000) {
|
||||
|
||||
@@ -594,6 +594,189 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.post('/jellyfin/quickconnect/initiate', async (req, res, next) => {
|
||||
try {
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(
|
||||
hostname ?? '',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const response = await jellyfinServer.initiateQuickConnect();
|
||||
|
||||
return res.status(200).json({
|
||||
code: response.Code,
|
||||
secret: response.Secret,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error initiating Jellyfin quick connect', {
|
||||
label: 'Auth',
|
||||
errorMessage: error.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Failed to initiate quick connect.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.get('/jellyfin/quickconnect/check', async (req, res, next) => {
|
||||
const secret = req.query.secret as string;
|
||||
|
||||
if (
|
||||
!secret ||
|
||||
typeof secret !== 'string' ||
|
||||
secret.length < 8 ||
|
||||
secret.length > 128 ||
|
||||
!/^[A-Fa-f0-9]+$/.test(secret)
|
||||
) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Invalid secret format',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(
|
||||
hostname ?? '',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const response = await jellyfinServer.checkQuickConnect(secret);
|
||||
|
||||
return res.status(200).json({ authenticated: response.Authenticated });
|
||||
} catch (e) {
|
||||
return next({
|
||||
status: e.statusCode || 500,
|
||||
message: 'Failed to check Quick Connect status',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.post(
|
||||
'/jellyfin/quickconnect/authenticate',
|
||||
async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
const body = req.body as { secret?: string };
|
||||
|
||||
if (
|
||||
!body.secret ||
|
||||
typeof body.secret !== 'string' ||
|
||||
body.secret.length < 8 ||
|
||||
body.secret.length > 128 ||
|
||||
!/^[A-Fa-f0-9]+$/.test(body.secret)
|
||||
) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Secret required',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED ||
|
||||
!(await userRepository.count())
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Quick Connect is not available during initial setup.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(
|
||||
hostname ?? '',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const account = await jellyfinServer.authenticateQuickConnect(
|
||||
body.secret
|
||||
);
|
||||
|
||||
let user = await userRepository.findOne({
|
||||
where: { jellyfinUserId: account.User.Id },
|
||||
});
|
||||
|
||||
const deviceId = Buffer.from(`BOT_seerr_qc_${account.User.Id}`).toString(
|
||||
'base64'
|
||||
);
|
||||
|
||||
if (user) {
|
||||
logger.info('Quick Connect sign-in from existing user', {
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUsername: account.User.Name,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
user.jellyfinAuthToken = account.AccessToken;
|
||||
user.jellyfinDeviceId = deviceId;
|
||||
user.avatar = getUserAvatarUrl(user);
|
||||
await userRepository.save(user);
|
||||
} else if (!settings.main.newPlexLogin) {
|
||||
logger.warn(
|
||||
'Failed Quick Connect sign-in attempt by unimported Jellyfin user',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinUsername: account.User.Name,
|
||||
}
|
||||
);
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Access denied.',
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
'Quick Connect sign-in from new Jellyfin user; creating new Seerr user',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUsername: account.User.Name,
|
||||
}
|
||||
);
|
||||
|
||||
user = new User({
|
||||
email: account.User.Name,
|
||||
jellyfinUsername: account.User.Name,
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinDeviceId: deviceId,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
userType:
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? UserType.JELLYFIN
|
||||
: UserType.EMBY,
|
||||
});
|
||||
user.avatar = getUserAvatarUrl(user);
|
||||
await userRepository.save(user);
|
||||
}
|
||||
|
||||
// Set session
|
||||
if (req.session) {
|
||||
req.session.userId = user.id;
|
||||
}
|
||||
|
||||
return res.status(200).json(user?.filter() ?? {});
|
||||
} catch (e) {
|
||||
logger.error('Quick Connect authentication failed', {
|
||||
label: 'Auth',
|
||||
error: e.message,
|
||||
ip: req.ip,
|
||||
});
|
||||
return next({
|
||||
status: e.statusCode || 500,
|
||||
message: ApiErrorCode.InvalidCredentials,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
authRoutes.post('/local', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
@@ -543,6 +543,81 @@ userSettingsRoutes.delete<{ id: string }>(
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.post<{ secret: string }>(
|
||||
'/linked-accounts/jellyfin/quickconnect',
|
||||
isOwnProfile(),
|
||||
async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ code: ApiErrorCode.Unauthorized });
|
||||
}
|
||||
|
||||
const secret = req.body.secret;
|
||||
if (
|
||||
!secret ||
|
||||
typeof secret !== 'string' ||
|
||||
secret.length < 8 ||
|
||||
secret.length > 128 ||
|
||||
!/^[A-Fa-f0-9]+$/.test(secret)
|
||||
) {
|
||||
return res.status(400).json({ message: 'Invalid secret format' });
|
||||
}
|
||||
|
||||
if (
|
||||
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||
settings.main.mediaServerType !== MediaServerType.EMBY
|
||||
) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: 'Jellyfin/Emby login is disabled' });
|
||||
}
|
||||
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(hostname);
|
||||
|
||||
try {
|
||||
const account = await jellyfinServer.authenticateQuickConnect(secret);
|
||||
|
||||
if (
|
||||
await userRepository.exist({
|
||||
where: { jellyfinUserId: account.User.Id },
|
||||
})
|
||||
) {
|
||||
return res.status(422).json({
|
||||
message: 'The specified account is already linked to a Seerr user',
|
||||
});
|
||||
}
|
||||
|
||||
const user = req.user;
|
||||
const deviceId = Buffer.from(
|
||||
`BOT_seerr_qc_link_${account.User.Id}`
|
||||
).toString('base64');
|
||||
|
||||
user.userType =
|
||||
settings.main.mediaServerType === MediaServerType.EMBY
|
||||
? UserType.EMBY
|
||||
: UserType.JELLYFIN;
|
||||
user.jellyfinUserId = account.User.Id;
|
||||
user.jellyfinUsername = account.User.Name;
|
||||
user.jellyfinAuthToken = account.AccessToken;
|
||||
user.jellyfinDeviceId = deviceId;
|
||||
await userRepository.save(user);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.error('Failed to link account with Quick Connect.', {
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
error: e,
|
||||
});
|
||||
|
||||
return res.status(500).send();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
'/notifications',
|
||||
isOwnProfileOrAdmin(),
|
||||
|
||||
@@ -25,7 +25,7 @@ const LabeledCheckbox: React.FC<LabeledCheckboxProps> = ({
|
||||
<Field type="checkbox" id={id} name={id} onChange={onChange} />
|
||||
</div>
|
||||
<div className="ml-3 text-sm leading-6">
|
||||
<label htmlFor="localLogin" className="block">
|
||||
<label htmlFor="localLogin" className="block" aria-label={label}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-white">{label}</span>
|
||||
<span className="font-normal text-gray-400">{description}</span>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||
import JellyfinQuickConnectModal from '@app/components/Login/JellyfinQuickConnectModal';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
ArrowLeftOnRectangleIcon,
|
||||
QrCodeIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { MediaServerType, ServerType } from '@server/constants/server';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import * as Yup from 'yup';
|
||||
@@ -25,6 +30,8 @@ const messages = defineMessages('components.Login', {
|
||||
signingin: 'Signing In…',
|
||||
signin: 'Sign In',
|
||||
forgotpassword: 'Forgot Password?',
|
||||
quickconnect: 'Quick Connect',
|
||||
quickconnecterror: 'Quick Connect failed. Please try again.',
|
||||
});
|
||||
|
||||
interface JellyfinLoginProps {
|
||||
@@ -32,13 +39,11 @@ interface JellyfinLoginProps {
|
||||
serverType?: MediaServerType;
|
||||
}
|
||||
|
||||
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
revalidate,
|
||||
serverType,
|
||||
}) => {
|
||||
const JellyfinLogin = ({ revalidate, serverType }: JellyfinLoginProps) => {
|
||||
const toasts = useToasts();
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const [showQuickConnect, setShowQuickConnect] = useState(false);
|
||||
|
||||
const mediaServerFormatValues = {
|
||||
mediaServerName:
|
||||
@@ -49,6 +54,16 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
: 'Media Server',
|
||||
};
|
||||
|
||||
const handleQuickConnectError = useCallback(
|
||||
(error: string) => {
|
||||
toasts.addToast(error, {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
},
|
||||
[toasts]
|
||||
);
|
||||
|
||||
const LoginSchema = Yup.object().shape({
|
||||
username: Yup.string().required(
|
||||
intl.formatMessage(messages.validationusernamerequired)
|
||||
@@ -194,6 +209,30 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
type="button"
|
||||
onClick={() => setShowQuickConnect(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<QrCodeIcon />
|
||||
<span>{intl.formatMessage(messages.quickconnect)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showQuickConnect && (
|
||||
<JellyfinQuickConnectModal
|
||||
onClose={() => setShowQuickConnect(false)}
|
||||
onAuthenticated={() => {
|
||||
setShowQuickConnect(false);
|
||||
revalidate();
|
||||
}}
|
||||
onError={handleQuickConnectError}
|
||||
mediaServerName={mediaServerFormatValues.mediaServerName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
154
src/components/Login/JellyfinQuickConnectModal.tsx
Normal file
154
src/components/Login/JellyfinQuickConnectModal.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import { useQuickConnect } from '@app/hooks/useQuickConnect';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import axios from 'axios';
|
||||
import { useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.Login.JellyfinQuickConnectModal', {
|
||||
title: 'Quick Connect',
|
||||
subtitle: 'Sign in with Quick Connect',
|
||||
instructions: 'Enter this code in your {mediaServerName} app',
|
||||
waitingForAuth: 'Waiting for authorization...',
|
||||
expired: 'Code Expired',
|
||||
expiredMessage: 'This Quick Connect code has expired. Please try again.',
|
||||
error: 'Error',
|
||||
cancel: 'Cancel',
|
||||
tryAgain: 'Try Again',
|
||||
});
|
||||
|
||||
interface JellyfinQuickConnectModalProps {
|
||||
onClose: () => void;
|
||||
onAuthenticated: () => void;
|
||||
onError: (error: string) => void;
|
||||
mediaServerName: string;
|
||||
}
|
||||
|
||||
const JellyfinQuickConnectModal = ({
|
||||
onClose,
|
||||
onAuthenticated,
|
||||
onError,
|
||||
mediaServerName,
|
||||
}: JellyfinQuickConnectModalProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const authenticate = useCallback(
|
||||
async (secret: string) => {
|
||||
await axios.post('/api/v1/auth/jellyfin/quickconnect/authenticate', {
|
||||
secret,
|
||||
});
|
||||
onAuthenticated();
|
||||
onClose();
|
||||
},
|
||||
[onAuthenticated, onClose]
|
||||
);
|
||||
|
||||
const {
|
||||
code,
|
||||
isLoading,
|
||||
hasError,
|
||||
isExpired,
|
||||
errorMessage,
|
||||
initiateQuickConnect,
|
||||
cleanup,
|
||||
} = useQuickConnect({
|
||||
show: true,
|
||||
onSuccess: () => {
|
||||
onAuthenticated();
|
||||
onClose();
|
||||
},
|
||||
onError,
|
||||
authenticate,
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
cleanup();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition
|
||||
as="div"
|
||||
appear
|
||||
show
|
||||
enter="transition-opacity ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Modal
|
||||
onCancel={handleClose}
|
||||
title={intl.formatMessage(messages.title)}
|
||||
subTitle={intl.formatMessage(messages.subtitle)}
|
||||
cancelText={intl.formatMessage(messages.cancel)}
|
||||
{...(hasError || isExpired
|
||||
? {
|
||||
okText: intl.formatMessage(messages.tryAgain),
|
||||
onOk: initiateQuickConnect,
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !hasError && !isExpired && (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<p className="text-center text-gray-300">
|
||||
{intl.formatMessage(messages.instructions, {
|
||||
mediaServerName,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="rounded-lg bg-gray-700 px-8 py-4">
|
||||
<span className="text-4xl font-bold tracking-wider text-white">
|
||||
{code}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-400">
|
||||
<div className="h-4 w-4">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<span>{intl.formatMessage(messages.waitingForAuth)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasError && (
|
||||
<div className="flex flex-col items-center space-y-4 py-4">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-red-500">
|
||||
{intl.formatMessage(messages.error)}
|
||||
</h3>
|
||||
<p className="mt-2 text-gray-300">{errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpired && (
|
||||
<div className="flex flex-col items-center space-y-4 py-4">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-yellow-500">
|
||||
{intl.formatMessage(messages.expired)}
|
||||
</h3>
|
||||
<p className="mt-2 text-gray-300">
|
||||
{intl.formatMessage(messages.expiredMessage)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
export default JellyfinQuickConnectModal;
|
||||
@@ -46,7 +46,7 @@ const NotificationType = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm leading-6">
|
||||
<label htmlFor={option.id} className="block">
|
||||
<label htmlFor={option.id} className="block" aria-label={option.name}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-white">{option.name}</span>
|
||||
<span className="font-normal text-gray-400">
|
||||
|
||||
@@ -123,7 +123,7 @@ const PermissionOption = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm leading-6">
|
||||
<label htmlFor={option.id} className="block">
|
||||
<label htmlFor={option.id} className="block" aria-label={option.name}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-white">{option.name}</span>
|
||||
<span className="font-normal text-gray-400">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import Alert from '@app/components/Common/Alert';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { QrCodeIcon } from '@heroicons/react/24/outline';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
@@ -27,6 +29,7 @@ const messages = defineMessages(
|
||||
'Unable to connect to {mediaServerName} using your credentials',
|
||||
errorExists: 'This account is already linked to a {applicationName} user',
|
||||
errorUnknown: 'An unknown error occurred',
|
||||
quickConnect: 'Use Quick Connect',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -34,13 +37,15 @@ interface LinkJellyfinModalProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onSwitchToQuickConnect: () => void;
|
||||
}
|
||||
|
||||
const LinkJellyfinModal: React.FC<LinkJellyfinModalProps> = ({
|
||||
const LinkJellyfinModal = ({
|
||||
show,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
onSwitchToQuickConnect,
|
||||
}: LinkJellyfinModalProps) => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { user } = useUser();
|
||||
@@ -167,6 +172,20 @@ const LinkJellyfinModal: React.FC<LinkJellyfinModalProps> = ({
|
||||
<div className="error">{errors.password}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
onSwitchToQuickConnect();
|
||||
}}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<QrCodeIcon />
|
||||
<span>{intl.formatMessage(messages.quickConnect)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import Alert from '@app/components/Common/Alert';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import { useQuickConnect } from '@app/hooks/useQuickConnect';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import axios from 'axios';
|
||||
import { useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages(
|
||||
'components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal',
|
||||
{
|
||||
title: 'Link {mediaServerName} Account',
|
||||
subtitle: 'Quick Connect',
|
||||
instructions: 'Enter this code in your {mediaServerName} app',
|
||||
waitingForAuth: 'Waiting for authorization...',
|
||||
expired: 'Code Expired',
|
||||
expiredMessage: 'This Quick Connect code has expired. Please try again.',
|
||||
error: 'Error',
|
||||
usePassword: 'Use Password Instead',
|
||||
tryAgain: 'Try Again',
|
||||
errorExists: 'This account is already linked',
|
||||
}
|
||||
);
|
||||
|
||||
interface LinkJellyfinQuickConnectModalProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onSwitchToPassword: () => void;
|
||||
}
|
||||
|
||||
const LinkJellyfinQuickConnectModal = ({
|
||||
show,
|
||||
onClose,
|
||||
onSave,
|
||||
onSwitchToPassword,
|
||||
}: LinkJellyfinQuickConnectModalProps) => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { user } = useUser();
|
||||
|
||||
const mediaServerName =
|
||||
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'Jellyfin'
|
||||
: 'Emby';
|
||||
|
||||
const authenticate = useCallback(
|
||||
async (secret: string) => {
|
||||
await axios.post(
|
||||
`/api/v1/user/${user?.id}/settings/linked-accounts/jellyfin/quickconnect`,
|
||||
{ secret }
|
||||
);
|
||||
onSave();
|
||||
onClose();
|
||||
},
|
||||
[user, onSave, onClose]
|
||||
);
|
||||
|
||||
const {
|
||||
code,
|
||||
isLoading,
|
||||
hasError,
|
||||
isExpired,
|
||||
errorMessage,
|
||||
initiateQuickConnect,
|
||||
cleanup,
|
||||
} = useQuickConnect({
|
||||
show: true,
|
||||
onSuccess: () => {
|
||||
onSave();
|
||||
onClose();
|
||||
},
|
||||
authenticate,
|
||||
});
|
||||
|
||||
const handleSwitchToPassword = () => {
|
||||
cleanup();
|
||||
onClose();
|
||||
onSwitchToPassword();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition
|
||||
as="div"
|
||||
appear
|
||||
show={show}
|
||||
enter="transition-opacity ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Modal
|
||||
onCancel={handleSwitchToPassword}
|
||||
title={intl.formatMessage(messages.title, { mediaServerName })}
|
||||
subTitle={intl.formatMessage(messages.subtitle)}
|
||||
cancelText={intl.formatMessage(messages.usePassword)}
|
||||
{...(hasError || isExpired
|
||||
? {
|
||||
okText: intl.formatMessage(messages.tryAgain),
|
||||
onOk: initiateQuickConnect,
|
||||
}
|
||||
: {})}
|
||||
dialogClass="sm:max-w-lg"
|
||||
>
|
||||
{errorMessage && (
|
||||
<div className="mb-4">
|
||||
<Alert type="error">{errorMessage}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !hasError && !isExpired && (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<p className="text-center text-gray-300">
|
||||
{intl.formatMessage(messages.instructions, { mediaServerName })}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="rounded-lg bg-gray-700 px-8 py-4">
|
||||
<span className="text-4xl font-bold tracking-wider text-white">
|
||||
{code}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-400">
|
||||
<div className="h-4 w-4">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<span>{intl.formatMessage(messages.waitingForAuth)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasError && (
|
||||
<div className="flex flex-col items-center space-y-4 py-4">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-red-500">
|
||||
{intl.formatMessage(messages.error)}
|
||||
</h3>
|
||||
<p className="mt-2 text-gray-300">{errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpired && (
|
||||
<div className="flex flex-col items-center space-y-4 py-4">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-yellow-500">
|
||||
{intl.formatMessage(messages.expired)}
|
||||
</h3>
|
||||
<p className="mt-2 text-gray-300">
|
||||
{intl.formatMessage(messages.expiredMessage)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkJellyfinQuickConnectModal;
|
||||
@@ -5,6 +5,7 @@ import Alert from '@app/components/Common/Alert';
|
||||
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
||||
import Dropdown from '@app/components/Common/Dropdown';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import LinkJellyfinQuickConnectModal from '@app/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinQuickConnectModal';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { Permission, UserType, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
@@ -63,6 +64,8 @@ const UserLinkedAccountsSettings = () => {
|
||||
user ? `/api/v1/user/${user?.id}/settings/password` : null
|
||||
);
|
||||
const [showJellyfinModal, setShowJellyfinModal] = useState(false);
|
||||
const [showJellyfinQuickConnectModal, setShowJellyfinQuickConnectModal] =
|
||||
useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const applicationName = settings.currentSettings.applicationTitle;
|
||||
@@ -263,6 +266,23 @@ const UserLinkedAccountsSettings = () => {
|
||||
setShowJellyfinModal(false);
|
||||
revalidateUser();
|
||||
}}
|
||||
onSwitchToQuickConnect={() => {
|
||||
setShowJellyfinModal(false);
|
||||
setShowJellyfinQuickConnectModal(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<LinkJellyfinQuickConnectModal
|
||||
show={showJellyfinQuickConnectModal}
|
||||
onClose={() => setShowJellyfinQuickConnectModal(false)}
|
||||
onSave={() => {
|
||||
setShowJellyfinQuickConnectModal(false);
|
||||
revalidateUser();
|
||||
}}
|
||||
onSwitchToPassword={() => {
|
||||
setShowJellyfinQuickConnectModal(false);
|
||||
setShowJellyfinModal(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
183
src/hooks/useQuickConnect.ts
Normal file
183
src/hooks/useQuickConnect.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import axios from 'axios';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('hooks.useQuickConnect', {
|
||||
errorMessage: 'Failed to initiate Quick Connect. Please try again.',
|
||||
});
|
||||
|
||||
interface UseQuickConnectOptions {
|
||||
show: boolean;
|
||||
onSuccess: () => void;
|
||||
onError?: (error: string) => void;
|
||||
authenticate: (secret: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useQuickConnect = ({
|
||||
show,
|
||||
onSuccess,
|
||||
onError,
|
||||
authenticate,
|
||||
}: UseQuickConnectOptions) => {
|
||||
const intl = useIntl();
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isExpired, setIsExpired] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const pollingInterval = useRef<NodeJS.Timeout>();
|
||||
const isMounted = useRef(true);
|
||||
const hasInitiated = useRef(false);
|
||||
const errorCount = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
const currentPollingInterval = pollingInterval.current;
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (currentPollingInterval) {
|
||||
clearInterval(currentPollingInterval);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
hasInitiated.current = false;
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
const authenticateWithQuickConnect = useCallback(
|
||||
async (secret: string) => {
|
||||
try {
|
||||
await authenticate(secret);
|
||||
if (!isMounted.current) return;
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
const errMsg =
|
||||
error?.response?.data?.message ||
|
||||
intl.formatMessage(messages.errorMessage);
|
||||
setErrorMessage(errMsg);
|
||||
setHasError(true);
|
||||
onError?.(errMsg);
|
||||
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
}
|
||||
},
|
||||
[authenticate, intl, onError, onSuccess]
|
||||
);
|
||||
|
||||
const startPolling = useCallback(
|
||||
(secret: string) => {
|
||||
pollingInterval.current = setInterval(async () => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
'/api/v1/auth/jellyfin/quickconnect/check',
|
||||
{
|
||||
params: { secret },
|
||||
}
|
||||
);
|
||||
|
||||
errorCount.current = 0;
|
||||
|
||||
if (!isMounted.current) {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data.authenticated) {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
await authenticateWithQuickConnect(secret);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
if (error?.response?.status === 404) {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
setIsExpired(true);
|
||||
} else {
|
||||
errorCount.current++;
|
||||
if (errorCount.current >= 5) {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
setHasError(true);
|
||||
const errorMessage = intl.formatMessage(messages.errorMessage);
|
||||
setErrorMessage(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
[authenticateWithQuickConnect, intl, onError]
|
||||
);
|
||||
|
||||
const initiateQuickConnect = useCallback(async () => {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
setIsExpired(false);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'/api/v1/auth/jellyfin/quickconnect/initiate'
|
||||
);
|
||||
|
||||
if (!isMounted.current) return;
|
||||
|
||||
setCode(response.data.code);
|
||||
setIsLoading(false);
|
||||
startPolling(response.data.secret);
|
||||
} catch (error) {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
setHasError(true);
|
||||
setIsLoading(false);
|
||||
const errMessage = intl.formatMessage(messages.errorMessage);
|
||||
setErrorMessage(errMessage);
|
||||
onError?.(errMessage);
|
||||
}
|
||||
}, [startPolling, onError, intl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (show && !hasInitiated.current) {
|
||||
hasInitiated.current = true;
|
||||
initiateQuickConnect();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [show]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
code,
|
||||
isLoading,
|
||||
hasError,
|
||||
isExpired,
|
||||
errorMessage,
|
||||
initiateQuickConnect,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { UserType } from '@server/constants/user';
|
||||
import type { PermissionCheckOptions } from '@server/lib/permissions';
|
||||
import { hasPermission, Permission } from '@server/lib/permissions';
|
||||
import type { NotificationAgentKey } from '@server/lib/settings';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { MutatorCallback } from 'swr';
|
||||
import useSWR from 'swr';
|
||||
|
||||
@@ -56,13 +57,21 @@ export const useUser = ({
|
||||
id,
|
||||
initialData,
|
||||
}: { id?: number; initialData?: User } = {}): UserHookResponse => {
|
||||
const router = useRouter();
|
||||
const isAuthPage = /^\/(login|setup|resetpassword(?:\/|$))/.test(
|
||||
router.pathname
|
||||
);
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
mutate: revalidate,
|
||||
} = useSWR<User>(id ? `/api/v1/user/${id}` : `/api/v1/auth/me`, {
|
||||
fallbackData: initialData,
|
||||
refreshInterval: 30000,
|
||||
refreshInterval: !isAuthPage ? 30000 : 0,
|
||||
revalidateOnFocus: !isAuthPage,
|
||||
revalidateOnMount: !isAuthPage,
|
||||
revalidateOnReconnect: !isAuthPage,
|
||||
errorRetryInterval: 30000,
|
||||
shouldRetryOnError: false,
|
||||
});
|
||||
|
||||
@@ -239,6 +239,17 @@
|
||||
"components.Layout.VersionStatus.outofdate": "Out of Date",
|
||||
"components.Layout.VersionStatus.streamdevelop": "Seerr Develop",
|
||||
"components.Layout.VersionStatus.streamstable": "Seerr Stable",
|
||||
"components.Login.JellyfinQuickConnectModal.authorizationFailed": "Quick Connect authorization failed.",
|
||||
"components.Login.JellyfinQuickConnectModal.cancel": "Cancel",
|
||||
"components.Login.JellyfinQuickConnectModal.error": "Error",
|
||||
"components.Login.JellyfinQuickConnectModal.errorMessage": "Failed to initiate Quick Connect. Please try again.",
|
||||
"components.Login.JellyfinQuickConnectModal.expired": "Code Expired",
|
||||
"components.Login.JellyfinQuickConnectModal.expiredMessage": "This Quick Connect code has expired. Please try again.",
|
||||
"components.Login.JellyfinQuickConnectModal.instructions": "Enter this code in your {mediaServerName} app",
|
||||
"components.Login.JellyfinQuickConnectModal.subtitle": "Sign in with Quick Connect",
|
||||
"components.Login.JellyfinQuickConnectModal.title": "Quick Connect",
|
||||
"components.Login.JellyfinQuickConnectModal.tryAgain": "Try Again",
|
||||
"components.Login.JellyfinQuickConnectModal.waitingForAuth": "Waiting for authorization...",
|
||||
"components.Login.adminerror": "You must use an admin account to sign in.",
|
||||
"components.Login.back": "Go back",
|
||||
"components.Login.credentialerror": "The username or password is incorrect.",
|
||||
@@ -257,6 +268,8 @@
|
||||
"components.Login.orsigninwith": "Or sign in with",
|
||||
"components.Login.password": "Password",
|
||||
"components.Login.port": "Port",
|
||||
"components.Login.quickconnect": "Quick Connect",
|
||||
"components.Login.quickconnecterror": "Quick Connect failed. Please try again.",
|
||||
"components.Login.save": "Add",
|
||||
"components.Login.saving": "Adding…",
|
||||
"components.Login.servertype": "Server Type",
|
||||
@@ -1383,11 +1396,23 @@
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.errorUnknown": "An unknown error occurred",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.password": "Password",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.passwordRequired": "You must provide a password",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.quickConnect": "Use Quick Connect",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.save": "Link",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.saving": "Adding…",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.title": "Link {mediaServerName} Account",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.username": "Username",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.usernameRequired": "You must provide a username",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.error": "Error",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.errorExists": "This account is already linked",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.errorMessage": "Failed to initiate Quick Connect. Please try again.",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.expired": "Code Expired",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.expiredMessage": "This Quick Connect code has expired. Please try again.",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.instructions": "Enter this code in your {mediaServerName} app",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.subtitle": "Quick Connect",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.title": "Link {mediaServerName} Account",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.tryAgain": "Try Again",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.usePassword": "Use Password Instead",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.waitingForAuth": "Waiting for authorization...",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Display Language",
|
||||
|
||||
@@ -63,7 +63,7 @@ class PlexOAuth {
|
||||
'X-Plex-Client-Identifier': clientId,
|
||||
'X-Plex-Model': 'Plex OAuth',
|
||||
'X-Plex-Platform': browser.getBrowserName(),
|
||||
'X-Plex-Platform-Version': browser.getBrowserVersion(),
|
||||
'X-Plex-Platform-Version': browser.getBrowserVersion() || 'Unknown',
|
||||
'X-Plex-Device': browser.getOSName(),
|
||||
'X-Plex-Device-Name': `${browser.getBrowserName()} (Seerr)`,
|
||||
'X-Plex-Device-Screen-Resolution':
|
||||
|
||||
Reference in New Issue
Block a user