feat: add jellyfin/emby quick connect authentication (#2212)

This commit is contained in:
fallenbagel
2026-07-03 19:32:20 +08:00
committed by GitHub
parent ebac489c8a
commit 74c8db42a6
13 changed files with 1452 additions and 7 deletions

View File

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

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[];
}
@@ -220,6 +237,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;

View File

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

View File

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

View File

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

View File

@@ -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<void>;
}
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 (
<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={handleCancel}
title={title}
subTitle={subTitle}
cancelText={cancelText}
dialogClass={dialogClass}
{...(hasError || isExpired
? {
okText: intl.formatMessage(messages.tryAgain),
onOk: initiateQuickConnect,
}
: {})}
>
{showInlineError && 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">{instructionsMessage}</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 QuickConnectModal;

View File

@@ -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<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:
@@ -51,6 +56,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)
@@ -201,6 +216,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,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 (
<QuickConnectModal
show
title={intl.formatMessage(messages.title)}
subTitle={intl.formatMessage(messages.subtitle)}
cancelText={intl.formatMessage(messages.cancel)}
instructionsMessage={intl.formatMessage(messages.instructions, {
mediaServerName,
})}
onCancel={onClose}
onSuccess={() => {
onAuthenticated();
onClose();
}}
onError={onError}
authenticate={authenticate}
/>
);
};
export default JellyfinQuickConnectModal;

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,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 (
<QuickConnectModal
show={show}
title={intl.formatMessage(messages.title, { mediaServerName })}
subTitle={intl.formatMessage(messages.subtitle)}
cancelText={intl.formatMessage(messages.usePassword)}
instructionsMessage={intl.formatMessage(messages.instructions, {
mediaServerName,
})}
dialogClass="sm:max-w-lg"
showInlineError
onCancel={handleCancel}
onSuccess={() => {
onSave();
onClose();
}}
authenticate={authenticate}
/>
);
};
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;
@@ -267,6 +270,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,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<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 | undefined>(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,
};
};

View File

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