Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions seerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3984,6 +3984,85 @@ paths:
required:
- username
- password
/auth/jellyfin/quickconnect/initiate:
post:
summary: Initiate Jellyfin Quick Connect
description: Initiates a Quick Connect session and returns a code for the user to authorize on their Jellyfin server.
security: []
tags:
- auth
responses:
'200':
description: Quick Connect session initiated
content:
application/json:
schema:
type: object
properties:
code:
type: string
example: '123456'
secret:
type: string
example: 'abc123def456'
'500':
description: Failed to initiate Quick Connect
/auth/jellyfin/quickconnect/check:
get:
summary: Check Quick Connect authorization status
description: Checks if the Quick Connect code has been authorized by the user.
security: []
tags:
- auth
parameters:
- in: query
name: secret
required: true
schema:
type: string
description: The secret returned from the initiate endpoint
responses:
'200':
description: Authorization status returned
content:
application/json:
schema:
type: object
properties:
authenticated:
type: boolean
example: false
'404':
description: Quick Connect session not found or expired
/auth/jellyfin/quickconnect/authenticate:
post:
summary: Authenticate with Quick Connect
description: Completes the Quick Connect authentication flow and creates a user session.
security: []
tags:
- auth
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
secret:
type: string
required:
- secret
responses:
'200':
description: Successfully authenticated
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'403':
description: Quick Connect not authorized or access denied
'500':
description: Authentication failed
/auth/local:
post:
summary: Sign in using a local account
Expand Down
73 changes: 73 additions & 0 deletions server/api/jellyfin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Expand Down Expand Up @@ -212,6 +229,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;
Expand Down
171 changes: 171 additions & 0 deletions server/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,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 secret = req.query.secret as string;

if (!secret || typeof secret !== 'string') {
return next({
status: 400,
message: 'Secret required',
});
}

try {
const hostname = getHostname();
const jellyfinServer = new JellyfinAPI(
hostname ?? '',
undefined,
undefined
);

const response = await jellyfinServer.checkQuickConnect(secret);

return res.status(200).json({ authenticated: response.Authenticated });
} catch (e) {
return next({
status: e.statusCode || 500,
message: 'Failed to check Quick Connect status',
});
}
});

authRoutes.post(
'/jellyfin/quickconnect/authenticate',
async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);
const body = req.body as { secret?: string };

if (!body.secret) {
return next({
status: 400,
message: 'Secret required',
});
}

if (
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED ||
!(await userRepository.count())
) {
return next({
status: 403,
message: 'Quick Connect is not available during initial setup.',
});
}

try {
const hostname = getHostname();
const jellyfinServer = new JellyfinAPI(
hostname ?? '',
undefined,
undefined
);

const account = await jellyfinServer.authenticateQuickConnect(
body.secret
);

let user = await userRepository.findOne({
where: { jellyfinUserId: account.User.Id },
});

const deviceId = Buffer.from(`BOT_seerr_qc_${account.User.Id}`).toString(
'base64'
);

if (user) {
logger.info('Quick Connect sign-in from existing user', {
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
userId: user.id,
});

user.jellyfinAuthToken = account.AccessToken;
user.jellyfinDeviceId = deviceId;
user.avatar = getUserAvatarUrl(user);
await userRepository.save(user);
} else if (!settings.main.newPlexLogin) {
logger.warn(
'Failed Quick Connect sign-in attempt by unimported Jellyfin user',
{
label: 'API',
ip: req.ip,
jellyfinUserId: account.User.Id,
jellyfinUsername: account.User.Name,
}
);
return next({
status: 403,
message: 'Access denied.',
});
} else {
logger.info(
'Quick Connect sign-in from new Jellyfin user; creating new Seerr user',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);

user = new User({
email: account.User.Name,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
permissions: settings.main.defaultPermissions,
userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
});
user.avatar = getUserAvatarUrl(user);
await userRepository.save(user);
}

// Set session
if (req.session) {
req.session.userId = user.id;
}

return res.status(200).json(user?.filter() ?? {});
} catch (e) {
logger.error('Quick Connect authentication failed', {
label: 'Auth',
error: e.message,
ip: req.ip,
});
return next({
status: e.statusCode || 500,
message: ApiErrorCode.InvalidCredentials,
});
}
}
);

authRoutes.post('/local', async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);
Expand Down
Loading
Loading