Compare commits

..

19 Commits

Author SHA1 Message Date
fallenbagel
f02755f03d fix: fixes some typos 2025-12-13 09:51:13 +08:00
fallenbagel
f2c771727c refactor(quickconnect): improve secret validation for quick connect endpoints 2025-12-13 09:43:01 +08:00
fallenbagel
87b51b809b refactor(quickconnect): implement useQuickConnect hook for managing quick connect flow 2025-12-13 09:34:58 +08:00
fallenbagel
43553cb2d5 refactor(jellyfin-login): simplify error handling for quick connect errors 2025-12-13 09:34:25 +08:00
fallenbagel
8bb7d4e380 refactor(quickconnect): validate secret length and format in quick connect check 2025-12-13 09:33:31 +08:00
fallenbagel
8c4e39d098 feat(openapi): add quick connect endpoint for linking jellyfin/emby accounts 2025-12-13 09:32:56 +08:00
fallenbagel
973e43f1cc chore(i18n): extracted translations 2025-12-13 08:36:29 +08:00
fallenbagel
a93716eb15 feat(linked-accounts): add quick connect linking in the linked-accounts module 2025-12-13 08:20:29 +08:00
fallenbagel
6000c36c69 fix(quick-connect): prevent multiple initiations of Quick Connect 2025-12-13 08:20:29 +08:00
fallenbagel
6c9aaf9777 fix(quick-connect): prevent memory leak by having one active poll at a time 2025-12-13 08:20:29 +08:00
fallenbagel
c4d06540a6 chore(i18n): extracted translations 2025-12-13 08:20:29 +08:00
fallenbagel
98a6075cb6 feat: add jellyfin/emby quick connect authentication
Implements a quick connect authentication flow for jellyfin and emby servers.

fix #1595
2025-12-13 08:20:29 +08:00
RolliePollie18
15356dfe49 fix(jellyfin-scan): reduce jellyfin API calls during recently added scan (#2205)
* fix(jellyfin scanner): reduce jellyfin API calls during recently added scan

Significantly reduce number of API calls, addressing CPU spikes on Jellyfin 10.10+ servers.- Move
getSeasons() call outside the seasons loop (N calls to 1)- Request MediaSources via getEpisodes()
field parameter instead of  individual getItemData() calls per episode (N calls to 1 per season)
Performance improvements (tested on library with 12 TV shows):- Scan duration: 43.7s to 9.1s - Peak
CPU: 277% to 115% - CPU spike duration: 36s to 2s Functionality is unchanged, all availability
statuses identicalbefore and after.

* fix: add getEpisodes overloads to remove unsafe type assertion

* refactor(jellyfin): use generics instead of overloads

---------

Co-authored-by: patrick-acland <patrick.acland@kraken.tech>
2025-12-09 22:20:47 +08:00
fallenbagel
1f04eeb040 fix: disable automatic auth revalidation on auth pages (#2213)
* fix: disable automatic auth revalidation on auth pages

Prevents unnecessary `/api/v1/auth/me` requests on login, setup, and password reset pages.

fix #738

* fix: update regex to include resetpassword guid & add missing condition in refreshInterval
2025-12-09 13:17:17 +01:00
Thibaut Noah
e3028c21f2 docs: add webpush related troubleshooting steps (#2170)
* Update troubleshooting.mdx

Add potential fixes for users who fail to enable their web push notifications

* Update docs/troubleshooting.mdx

Modify appName syntax for better coding norm

Co-authored-by: Gauthier <mail@gauthierth.fr>

* refactor: apply suggestions from review comments

Co-authored-by: Gauthier <mail@gauthierth.fr>

* docs(troubleshooting): fix typos in troubleshooting doc page

---------

Co-authored-by: Gauthier <mail@gauthierth.fr>
Co-authored-by: fallenbagel <98979876+fallenbagel@users.noreply.github.com>
2025-12-09 08:49:42 +00:00
Gauthier
9d8b343790 chore(deps): update all non-major dependencies (#2188)
Update all non-major dependencies. Modifications in `src` files are there to fix linting issues.
2025-12-09 09:40:35 +01:00
fallenbagel
f4fe16608a fix(jellyfin-api): use standard Authorization header (#2211)
Replace X-Emby-Authorization with Authorization header to fix authentication failures when users
have <EnableLegacyAuthorization>false</EnableLegacyAuthorization> in their Jellyfin system.xml.
2025-12-08 15:46:47 +01:00
Ludovic Ortega
d660a540da chore(helm): prepare for release (#2189)
Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2025-12-07 17:22:28 +01:00
Ludovic Ortega
48ef2984e5 docs: fix chown command for windows users (#2192)
Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2025-12-03 14:39:03 +01:00
25 changed files with 3600 additions and 1852 deletions

View File

@@ -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! -->

View File

@@ -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

View File

@@ -1,6 +1,6 @@
# seerr-chart
![Version: 3.0.0](https://img.shields.io/badge/Version-3.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.7.3](https://img.shields.io/badge/AppVersion-2.7.3-informational?style=flat-square)
![Version: 3.0.0](https://img.shields.io/badge/Version-3.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 3.0.0](https://img.shields.io/badge/AppVersion-3.0.0-informational?style=flat-square)
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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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(),

View File

@@ -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>

View File

@@ -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>
);
};

View 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;

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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);
}}
/>
</>
);

View 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,
};
};

View File

@@ -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,
});

View File

@@ -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",

View File

@@ -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':