From 74c8db42a643c2c63e16cdd58ba790f6adfb7ab5 Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+fallenbagel@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:32:20 +0800 Subject: [PATCH] feat: add jellyfin/emby quick connect authentication (#2212) --- seerr-api.yml | 113 +++++ server/api/jellyfin.ts | 73 +++ server/routes/auth.test.ts | 439 ++++++++++++++++++ server/routes/auth.ts | 180 +++++++ server/routes/user/usersettings.ts | 73 +++ .../Common/QuickConnectModal/index.tsx | 152 ++++++ src/components/Login/JellyfinLogin.tsx | 49 +- .../Login/JellyfinQuickConnectModal.tsx | 55 +++ .../LinkJellyfinModal.tsx | 23 +- .../LinkJellyfinQuickConnectModal.tsx | 78 ++++ .../UserLinkedAccountsSettings/index.tsx | 20 + src/hooks/useQuickConnect.ts | 187 ++++++++ src/i18n/locale/en.json | 17 + 13 files changed, 1452 insertions(+), 7 deletions(-) create mode 100644 src/components/Common/QuickConnectModal/index.tsx create mode 100644 src/components/Login/JellyfinQuickConnectModal.tsx create mode 100644 src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinQuickConnectModal.tsx create mode 100644 src/hooks/useQuickConnect.ts diff --git a/seerr-api.yml b/seerr-api.yml index 6d58dd3cb..cd0012b67 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -3998,6 +3998,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 @@ -5161,6 +5240,40 @@ 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: + '400': + description: Invalid Quick Connect secret + '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 diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index 28afc3e0e..962413ae6 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -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[]; } @@ -220,6 +237,62 @@ class JellyfinAPI extends ExternalAPI { } } + public async initiateQuickConnect(): Promise { + try { + const response = await this.post( + '/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 { + try { + const response = await this.get( + '/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 { + try { + const response = await this.post( + '/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; diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts index 0e7981a23..5ae5ef59d 100644 --- a/server/routes/auth.test.ts +++ b/server/routes/auth.test.ts @@ -1,12 +1,17 @@ import assert from 'node:assert/strict'; import { before, beforeEach, describe, it, mock } from 'node:test'; +import JellyfinAPI from '@server/api/jellyfin'; +import { ApiErrorCode } from '@server/constants/error'; +import { MediaServerType } from '@server/constants/server'; +import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import PreparedEmail from '@server/lib/email'; import { getSettings } from '@server/lib/settings'; import { checkUser } from '@server/middleware/auth'; import { setupTestDb } from '@server/test/db'; +import { ApiError } from '@server/types/error'; import type { Express } from 'express'; import express from 'express'; import session from 'express-session'; @@ -17,6 +22,52 @@ const emailMock = mock.method(PreparedEmail.prototype, 'send', async () => { return undefined; }).mock; +// Jellyfin Quick Connect mocks +const defaultInitiateResponse = { + Secret: 'abc123def456abc123def456', + Code: '123456', + DateAdded: new Date().toISOString(), +}; + +const defaultCheckResponse = { + Authenticated: false, + Secret: 'abc123def456abc123def456', + Code: '123456', + DeviceId: 'device-1', + DeviceName: 'Test', + AppName: 'Seerr', + AppVersion: '1.0', + DateAdded: new Date().toISOString(), +}; + +const defaultAuthenticateResponse = { + User: { + Id: 'jf-qc-user-001', + Name: 'quickconnectuser', + ServerId: 'server-1', + Policy: { IsAdministrator: false }, + }, + AccessToken: 'fake-qc-access-token', +}; + +const initiateQCMock = mock.method( + JellyfinAPI.prototype, + 'initiateQuickConnect', + async () => ({ ...defaultInitiateResponse }) +); + +const checkQCMock = mock.method( + JellyfinAPI.prototype, + 'checkQuickConnect', + async () => ({ ...defaultCheckResponse }) +); + +const authenticateQCMock = mock.method( + JellyfinAPI.prototype, + 'authenticateQuickConnect', + async () => ({ ...defaultAuthenticateResponse }) +); + let app: Express; function createApp() { @@ -67,6 +118,394 @@ async function authenticatedAgent(email: string, password: string) { return agent; } +/** Configure Jellyfin settings for testing QC */ +function configureJellyfin() { + const settings = getSettings(); + settings.main.mediaServerType = MediaServerType.JELLYFIN; + settings.main.newPlexLogin = true; + settings.jellyfin.ip = 'localhost'; + settings.jellyfin.port = 8096; + settings.jellyfin.useSsl = false; + settings.jellyfin.urlBase = ''; +} + +describe('POST /auth/jellyfin/quickconnect/initiate', () => { + beforeEach(() => { + initiateQCMock.mock.resetCalls(); + initiateQCMock.mock.mockImplementation(async () => ({ + ...defaultInitiateResponse, + })); + configureJellyfin(); + }); + + it('returns code and secret on success', async () => { + const res = await request(app).post('/auth/jellyfin/quickconnect/initiate'); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.code, '123456'); + assert.strictEqual(res.body.secret, 'abc123def456abc123def456'); + assert.strictEqual(initiateQCMock.mock.callCount(), 1); + }); + + it('returns 500 when Jellyfin API fails', async () => { + initiateQCMock.mock.mockImplementation(async () => { + throw new Error('Connection refused'); + }); + + const res = await request(app).post('/auth/jellyfin/quickconnect/initiate'); + + assert.strictEqual(res.status, 500); + assert.match(res.body.message, /initiate quick connect/i); + }); + + it('returns 500 when initiateQuickConnect throws ApiError', async () => { + initiateQCMock.mock.mockImplementation(async () => { + throw new ApiError(500, ApiErrorCode.Unknown); + }); + + const res = await request(app).post('/auth/jellyfin/quickconnect/initiate'); + assert.strictEqual(res.status, 500); + }); +}); + +describe('GET /auth/jellyfin/quickconnect/check', () => { + beforeEach(() => { + checkQCMock.mock.resetCalls(); + checkQCMock.mock.mockImplementation(async () => ({ + ...defaultCheckResponse, + })); + configureJellyfin(); + }); + + it('returns authenticated: false when not yet authorized', async () => { + const res = await request(app) + .get('/auth/jellyfin/quickconnect/check') + .query({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.authenticated, false); + assert.strictEqual(checkQCMock.mock.callCount(), 1); + }); + + it('returns authenticated: true when authorized', async () => { + checkQCMock.mock.mockImplementation(async () => ({ + ...defaultCheckResponse, + Authenticated: true, + })); + + const res = await request(app) + .get('/auth/jellyfin/quickconnect/check') + .query({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.authenticated, true); + }); + + it('returns 400 when secret is missing', async () => { + const res = await request(app).get('/auth/jellyfin/quickconnect/check'); + + assert.strictEqual(res.status, 400); + assert.match(res.body.message, /invalid secret/i); + assert.strictEqual(checkQCMock.mock.callCount(), 0); + }); + + it('returns 400 when secret is too short', async () => { + const res = await request(app) + .get('/auth/jellyfin/quickconnect/check') + .query({ secret: 'ab12' }); + + assert.strictEqual(res.status, 400); + assert.match(res.body.message, /invalid secret/i); + assert.strictEqual(checkQCMock.mock.callCount(), 0); + }); + + it('returns 400 when secret is too long', async () => { + const res = await request(app) + .get('/auth/jellyfin/quickconnect/check') + .query({ secret: 'a'.repeat(129) }); + + assert.strictEqual(res.status, 400); + assert.match(res.body.message, /invalid secret/i); + assert.strictEqual(checkQCMock.mock.callCount(), 0); + }); + + it('returns 400 when secret contains non-hex characters', async () => { + const res = await request(app) + .get('/auth/jellyfin/quickconnect/check') + .query({ secret: 'zzzzzzzzzzzz' }); + + assert.strictEqual(res.status, 400); + assert.match(res.body.message, /invalid secret/i); + assert.strictEqual(checkQCMock.mock.callCount(), 0); + }); + + it('returns error when Jellyfin API fails', async () => { + checkQCMock.mock.mockImplementation(async () => { + throw new ApiError(500, ApiErrorCode.Unknown); + }); + + const res = await request(app) + .get('/auth/jellyfin/quickconnect/check') + .query({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 500); + }); +}); + +describe('POST /auth/jellyfin/quickconnect/authenticate', () => { + beforeEach(() => { + authenticateQCMock.mock.resetCalls(); + authenticateQCMock.mock.mockImplementation(async () => ({ + ...defaultAuthenticateResponse, + })); + configureJellyfin(); + }); + + it('returns 400 when secret is missing', async () => { + const res = await request(app) + .post('/auth/jellyfin/quickconnect/authenticate') + .send({}); + + assert.strictEqual(res.status, 400); + assert.strictEqual(authenticateQCMock.mock.callCount(), 0); + }); + + it('returns 400 when secret is not a string', async () => { + const res = await request(app) + .post('/auth/jellyfin/quickconnect/authenticate') + .send({ secret: 12345678 }); + + assert.strictEqual(res.status, 400); + assert.strictEqual(authenticateQCMock.mock.callCount(), 0); + }); + + it('returns 400 when secret is too short', async () => { + const res = await request(app) + .post('/auth/jellyfin/quickconnect/authenticate') + .send({ secret: 'ab12' }); + + assert.strictEqual(res.status, 400); + }); + + it('returns 400 when secret contains non-hex characters', async () => { + const res = await request(app) + .post('/auth/jellyfin/quickconnect/authenticate') + .send({ secret: 'zzzzzzzzzzzz' }); + + assert.strictEqual(res.status, 400); + assert.strictEqual(authenticateQCMock.mock.callCount(), 0); + }); + + it('returns 403 when media server is not configured', async () => { + const settings = getSettings(); + settings.main.mediaServerType = MediaServerType.NOT_CONFIGURED; + + const res = await request(app) + .post('/auth/jellyfin/quickconnect/authenticate') + .send({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 403); + assert.match(res.body.message, /initial setup/i); + assert.strictEqual(authenticateQCMock.mock.callCount(), 0); + }); + + it('returns 403 when no users exist in the database', async () => { + // Clear all users to simulate initial setup + const userRepo = getRepository(User); + await userRepo.clear(); + + const res = await request(app) + .post('/auth/jellyfin/quickconnect/authenticate') + .send({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 403); + assert.match(res.body.message, /initial setup/i); + }); + + it('signs in an existing Jellyfin user and sets session', async () => { + const userRepo = getRepository(User); + const existingUser = new User({ + email: 'existing-qc@seerr.dev', + jellyfinUsername: 'quickconnectuser', + jellyfinUserId: 'jf-qc-user-001', + jellyfinDeviceId: 'old-device-id', + permissions: 0, + avatar: '/avatarproxy/jf-qc-user-001?v=0', + userType: UserType.JELLYFIN, + }); + await userRepo.save(existingUser); + + const agent = request.agent(app); + + const res = await agent + .post('/auth/jellyfin/quickconnect/authenticate') + .send({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 200); + assert.ok('id' in res.body); + assert.ok(!('password' in res.body)); + + const meRes = await agent.get('/auth/me'); + assert.strictEqual(meRes.status, 200); + assert.strictEqual(meRes.body.jellyfinUsername, 'quickconnectuser'); + + const updatedUser = await userRepo.findOneOrFail({ + where: { jellyfinUserId: 'jf-qc-user-001' }, + select: { + id: true, + jellyfinAuthToken: true, + jellyfinDeviceId: true, + }, + }); + assert.strictEqual(updatedUser.jellyfinAuthToken, 'fake-qc-access-token'); + assert.notStrictEqual(updatedUser.jellyfinDeviceId, 'old-device-id'); + }); + + it('creates a new user when newPlexLogin is enabled and user does not exist', async () => { + const settings = getSettings(); + settings.main.newPlexLogin = true; + + authenticateQCMock.mock.mockImplementation(async () => ({ + User: { + Id: 'jf-brand-new-user', + Name: 'brandnewuser', + ServerId: 'server-1', + Policy: { IsAdministrator: false }, + }, + AccessToken: 'new-user-token', + })); + + const agent = request.agent(app); + + const res = await agent + .post('/auth/jellyfin/quickconnect/authenticate') + .send({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 200); + assert.ok('id' in res.body); + + const userRepo = getRepository(User); + const newUser = await userRepo.findOne({ + where: { jellyfinUserId: 'jf-brand-new-user' }, + }); + assert.ok(newUser); + assert.strictEqual(newUser.jellyfinUsername, 'brandnewuser'); + assert.strictEqual(newUser.userType, UserType.JELLYFIN); + + const meRes = await agent.get('/auth/me'); + assert.strictEqual(meRes.status, 200); + }); + + it('sets userType to EMBY when media server is Emby', async () => { + const settings = getSettings(); + settings.main.mediaServerType = MediaServerType.EMBY; + settings.main.newPlexLogin = true; + + authenticateQCMock.mock.mockImplementation(async () => ({ + User: { + Id: 'emby-new-user', + Name: 'embyuser', + ServerId: 'server-1', + Policy: { IsAdministrator: false }, + }, + AccessToken: 'emby-token', + })); + + const agent = request.agent(app); + const res = await agent + .post('/auth/jellyfin/quickconnect/authenticate') + .send({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 200); + + const meRes = await agent.get('/auth/me'); + assert.strictEqual(meRes.status, 200); + assert.strictEqual(meRes.body.jellyfinUsername, 'embyuser'); + + const userRepo = getRepository(User); + const user = await userRepo.findOne({ + where: { jellyfinUserId: 'emby-new-user' }, + }); + assert.ok(user); + assert.strictEqual(user.userType, UserType.EMBY); + }); + + it('applies default permissions to newly created users', async () => { + const settings = getSettings(); + settings.main.newPlexLogin = true; + settings.main.defaultPermissions = 32; + + authenticateQCMock.mock.mockImplementation(async () => ({ + User: { + Id: 'jf-perms-test-user', + Name: 'permsuser', + ServerId: 'server-1', + Policy: { IsAdministrator: false }, + }, + AccessToken: 'perms-token', + })); + + const res = await request(app) + .post('/auth/jellyfin/quickconnect/authenticate') + .send({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 200); + + const userRepo = getRepository(User); + const user = await userRepo.findOneOrFail({ + where: { jellyfinUserId: 'jf-perms-test-user' }, + }); + assert.strictEqual(user.permissions, 32); + }); + + it('returns 403 when newPlexLogin is disabled and user does not exist', async () => { + const settings = getSettings(); + settings.main.newPlexLogin = false; + + authenticateQCMock.mock.mockImplementation(async () => ({ + User: { + Id: 'jf-unknown-user', + Name: 'unknownuser', + ServerId: 'server-1', + Policy: { IsAdministrator: false }, + }, + AccessToken: 'unknown-token', + })); + + const res = await request(app) + .post('/auth/jellyfin/quickconnect/authenticate') + .send({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.message, 'Access denied.'); + }); + + it('returns error when Jellyfin authenticateQuickConnect fails', async () => { + authenticateQCMock.mock.mockImplementation(async () => { + throw new ApiError(401, ApiErrorCode.InvalidCredentials); + }); + + const res = await request(app) + .post('/auth/jellyfin/quickconnect/authenticate') + .send({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 401); + assert.strictEqual(res.body.message, ApiErrorCode.InvalidCredentials); + }); + + it('returns 500 when Jellyfin throws a generic error', async () => { + authenticateQCMock.mock.mockImplementation(async () => { + throw new Error('Network timeout'); + }); + + const res = await request(app) + .post('/auth/jellyfin/quickconnect/authenticate') + .send({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 500); + }); +}); + describe('GET /auth/me', () => { it('returns 403 when not authenticated', async () => { const res = await request(app).get('/auth/me'); diff --git a/server/routes/auth.ts b/server/routes/auth.ts index f625e68f0..ab22ceba7 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -18,9 +18,18 @@ import axios from 'axios'; import { Router } from 'express'; import net from 'net'; import validator from 'validator'; +import { z } from 'zod'; const authRoutes = Router(); +export const quickConnectSecret = z.object({ + secret: z + .string() + .min(8) + .max(128) + .regex(/^[A-Fa-f0-9]+$/), +}); + authRoutes.get('/me', isAuthenticated(), async (req, res) => { const userRepository = getRepository(User); if (!req.user) { @@ -593,6 +602,177 @@ 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 result = quickConnectSecret.safeParse(req.query); + if (!result.success) { + return next({ + status: 400, + message: 'Invalid secret format', + }); + } + + const { secret } = result.data; + + 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 result = quickConnectSecret.safeParse(req.body); + if (!result.success) { + return next({ + status: 400, + message: 'Secret required', + }); + } + + const { secret } = result.data; + + 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(secret); + + let user = await userRepository.findOne({ + where: { jellyfinUserId: account.User.Id }, + }); + + const deviceId = Buffer.from( + `BOT_seerr_${account.User.Name ?? ''}` + ).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); diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index b54254a4f..bff500d58 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -14,6 +14,7 @@ import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; +import { quickConnectSecret } from '@server/routes/auth'; import { ApiError } from '@server/types/error'; import { getHostname } from '@server/utils/getHostname'; import { @@ -514,6 +515,78 @@ 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 result = quickConnectSecret.safeParse(req.body); + if (!result.success) { + return res.status(400).json({ message: 'Invalid secret format' }); + } + + const { secret } = result.data; + + 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( + user.id === 1 ? 'BOT_seerr' : `BOT_seerr_${user.username ?? ''}` + ).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, + }); + + const status = e instanceof ApiError ? e.statusCode : 500; + return res.status(status).send(); + } + } +); + userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( '/notifications', isOwnProfileOrAdmin(), diff --git a/src/components/Common/QuickConnectModal/index.tsx b/src/components/Common/QuickConnectModal/index.tsx new file mode 100644 index 000000000..b81a3383d --- /dev/null +++ b/src/components/Common/QuickConnectModal/index.tsx @@ -0,0 +1,152 @@ +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 defineMessages from '@app/utils/defineMessages'; +import { Transition } from '@headlessui/react'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages('components.Common.QuickConnectModal', { + waitingForAuth: 'Waiting for authorization...', + expired: 'Code Expired', + expiredMessage: 'This Quick Connect code has expired. Please try again.', + error: 'Error', + tryAgain: 'Try Again', +}); + +interface QuickConnectModalProps { + show: boolean; + title: string; + subTitle: string; + cancelText: string; + instructionsMessage: string; + dialogClass?: string; + showInlineError?: boolean; + onCancel: () => void; + onSuccess: () => void; + onError?: (error: string) => void; + authenticate: (secret: string) => Promise; +} + +const QuickConnectModal = ({ + show, + title, + subTitle, + cancelText, + instructionsMessage, + dialogClass, + showInlineError, + onCancel, + onSuccess, + onError, + authenticate, +}: QuickConnectModalProps) => { + const intl = useIntl(); + + const { + code, + isLoading, + hasError, + isExpired, + errorMessage, + initiateQuickConnect, + cleanup, + } = useQuickConnect({ + show, + onSuccess, + onError, + authenticate, + }); + + const handleCancel = () => { + cleanup(); + onCancel(); + }; + + return ( + + + {showInlineError && errorMessage && ( +
+ {errorMessage} +
+ )} + + {isLoading && ( +
+ +
+ )} + + {!isLoading && !hasError && !isExpired && ( +
+

{instructionsMessage}

+ +
+
+ + {code} + +
+
+ +
+
+ +
+ {intl.formatMessage(messages.waitingForAuth)} +
+
+ )} + + {hasError && ( +
+
+

+ {intl.formatMessage(messages.error)} +

+

{errorMessage}

+
+
+ )} + + {isExpired && ( +
+
+

+ {intl.formatMessage(messages.expired)} +

+

+ {intl.formatMessage(messages.expiredMessage)} +

+
+
+ )} +
+
+ ); +}; + +export default QuickConnectModal; diff --git a/src/components/Login/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx index 5873b87d2..01278e4a9 100644 --- a/src/components/Login/JellyfinLogin.tsx +++ b/src/components/Login/JellyfinLogin.tsx @@ -1,14 +1,19 @@ 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 useToasts from '@app/hooks/useToasts'; import defineMessages from '@app/utils/defineMessages'; -import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline'; +import { + ArrowLeftOnRectangleIcon, + QrCodeIcon, +} from '@heroicons/react/24/outline'; import { ExclamationTriangleIcon } from '@heroicons/react/24/solid'; 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 * as Yup from 'yup'; @@ -27,6 +32,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 { @@ -34,13 +41,11 @@ interface JellyfinLoginProps { serverType?: MediaServerType; } -const JellyfinLogin: React.FC = ({ - revalidate, - serverType, -}) => { +const JellyfinLogin = ({ revalidate, serverType }: JellyfinLoginProps) => { const toasts = useToasts(); const intl = useIntl(); const settings = useSettings(); + const [showQuickConnect, setShowQuickConnect] = useState(false); const mediaServerFormatValues = { mediaServerName: @@ -51,6 +56,16 @@ const JellyfinLogin: React.FC = ({ : '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) @@ -201,6 +216,30 @@ const JellyfinLogin: React.FC = ({ ); }} + +
+ +
+ + {showQuickConnect && ( + setShowQuickConnect(false)} + onAuthenticated={() => { + setShowQuickConnect(false); + revalidate(); + }} + onError={handleQuickConnectError} + mediaServerName={mediaServerFormatValues.mediaServerName} + /> + )} ); }; diff --git a/src/components/Login/JellyfinQuickConnectModal.tsx b/src/components/Login/JellyfinQuickConnectModal.tsx new file mode 100644 index 000000000..54298d2cf --- /dev/null +++ b/src/components/Login/JellyfinQuickConnectModal.tsx @@ -0,0 +1,55 @@ +import QuickConnectModal from '@app/components/Common/QuickConnectModal'; +import defineMessages from '@app/utils/defineMessages'; +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', + cancel: 'Cancel', +}); + +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, + }); + }, []); + + return ( + { + onAuthenticated(); + onClose(); + }} + onError={onError} + authenticate={authenticate} + /> + ); +}; + +export default JellyfinQuickConnectModal; diff --git a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx index b06bbcfdc..98cf04add 100644 --- a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx @@ -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 = ({ +const LinkJellyfinModal = ({ show, onClose, onSave, -}) => { + onSwitchToQuickConnect, +}: LinkJellyfinModalProps) => { const intl = useIntl(); const settings = useSettings(); const { user } = useUser(); @@ -167,6 +172,20 @@ const LinkJellyfinModal: React.FC = ({
{errors.password}
)} +
+ +
); diff --git a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinQuickConnectModal.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinQuickConnectModal.tsx new file mode 100644 index 000000000..c253b21d6 --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinQuickConnectModal.tsx @@ -0,0 +1,78 @@ +import QuickConnectModal from '@app/components/Common/QuickConnectModal'; +import useSettings from '@app/hooks/useSettings'; +import { useUser } from '@app/hooks/useUser'; +import defineMessages from '@app/utils/defineMessages'; +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', + usePassword: 'Use Password Instead', + } +); + +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 } + ); + }, + [user] + ); + + const handleCancel = () => { + onClose(); + onSwitchToPassword(); + }; + + return ( + { + onSave(); + onClose(); + }} + authenticate={authenticate} + /> + ); +}; + +export default LinkJellyfinQuickConnectModal; diff --git a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx index 994082829..d4b5365d5 100644 --- a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx @@ -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(null); const applicationName = settings.currentSettings.applicationTitle; @@ -267,6 +270,23 @@ const UserLinkedAccountsSettings = () => { setShowJellyfinModal(false); revalidateUser(); }} + onSwitchToQuickConnect={() => { + setShowJellyfinModal(false); + setShowJellyfinQuickConnectModal(true); + }} + /> + + setShowJellyfinQuickConnectModal(false)} + onSave={() => { + setShowJellyfinQuickConnectModal(false); + revalidateUser(); + }} + onSwitchToPassword={() => { + setShowJellyfinQuickConnectModal(false); + setShowJellyfinModal(true); + }} /> ); diff --git a/src/hooks/useQuickConnect.ts b/src/hooks/useQuickConnect.ts new file mode 100644 index 000000000..c9494ee7f --- /dev/null +++ b/src/hooks/useQuickConnect.ts @@ -0,0 +1,187 @@ +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; +} + +export const useQuickConnect = ({ + show, + onSuccess, + onError, + authenticate, +}: UseQuickConnectOptions) => { + const intl = useIntl(); + const [code, setCode] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const [isExpired, setIsExpired] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const pollingInterval = useRef(undefined); + const isMounted = useRef(true); + const hasInitiated = useRef(false); + const errorCount = useRef(0); + + useEffect(() => { + isMounted.current = true; + + return () => { + isMounted.current = false; + if (pollingInterval.current) { + clearInterval(pollingInterval.current); + } + }; + }, []); + + useEffect(() => { + if (!show) { + hasInitiated.current = false; + if (pollingInterval.current) { + clearInterval(pollingInterval.current); + pollingInterval.current = undefined; + } + } + }, [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); + setCode(''); + + 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 { + 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, + }; +}; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 21c9c8af1..1c4b5b569 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -21,6 +21,11 @@ "components.CollectionDetails.removefromblocklistpartialcount": "{removeLabel} ({count, plural, one {# movie} other {# movies}})", "components.CollectionDetails.requestcollection": "Request Collection", "components.CollectionDetails.requestcollection4k": "Request Collection in 4K", + "components.Common.QuickConnectModal.error": "Error", + "components.Common.QuickConnectModal.expired": "Code Expired", + "components.Common.QuickConnectModal.expiredMessage": "This Quick Connect code has expired. Please try again.", + "components.Common.QuickConnectModal.tryAgain": "Try Again", + "components.Common.QuickConnectModal.waitingForAuth": "Waiting for authorization...", "components.Discover.CreateSlider.addSlider": "Add Slider", "components.Discover.CreateSlider.addcustomslider": "Create Custom Slider", "components.Discover.CreateSlider.addfail": "Failed to create new slider.", @@ -244,6 +249,10 @@ "components.Layout.VersionStatus.outofdate": "Out of Date", "components.Layout.VersionStatus.streamdevelop": "Seerr Develop", "components.Layout.VersionStatus.streamstable": "Seerr Stable", + "components.Login.JellyfinQuickConnectModal.cancel": "Cancel", + "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.adminerror": "You must use an admin account to sign in.", "components.Login.back": "Go back", "components.Login.credentialerror": "The username or password is incorrect.", @@ -264,6 +273,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.saveFailed": "Something went wrong while saving.", "components.Login.saving": "Adding…", @@ -1449,11 +1460,16 @@ "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.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.usePassword": "Use Password Instead", "components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type", "components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin", "components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Display Language", @@ -1601,6 +1617,7 @@ "components.UserProfile.seriesrequest": "Series Requests", "components.UserProfile.totalrequests": "Total Requests", "components.UserProfile.unlimited": "Unlimited", + "hooks.useQuickConnect.errorMessage": "Failed to initiate Quick Connect. Please try again.", "i18n.addToBlocklist": "Add to Blocklist", "i18n.advanced": "Advanced", "i18n.all": "All",