mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-23 18:29:19 -05:00
Compare commits
18 Commits
4e9ba75377
...
preview-em
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32d8c07daf | ||
|
|
47cc0c6079 | ||
|
|
8de83b5752 | ||
|
|
d9f8d200d9 | ||
|
|
e39ad0acfa | ||
|
|
3936f3703f | ||
|
|
23bc1bd44b | ||
|
|
724727483c | ||
|
|
527bb1c2da | ||
|
|
7ae5eec648 | ||
|
|
5cc6b0aeb9 | ||
|
|
7973ad0ecd | ||
|
|
4c9b5484a8 | ||
|
|
d1cec17e0b | ||
|
|
844bfe65a2 | ||
|
|
a5979933f8 | ||
|
|
0e588bf315 | ||
|
|
f5b3b06dfe |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -71,3 +71,6 @@ tsconfig.tsbuildinfo
|
||||
|
||||
# Config Cache Directory
|
||||
config/cache
|
||||
|
||||
# Mise
|
||||
mise.toml
|
||||
|
||||
211
server/api/embyconnect.ts
Normal file
211
server/api/embyconnect.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { ApiError } from '@server/types/error';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
import { uniqueId } from 'lodash';
|
||||
import type { JellyfinLoginResponse } from './jellyfin';
|
||||
|
||||
export interface ConnectAuthResponse {
|
||||
AccessToken: string;
|
||||
User: {
|
||||
Id: string;
|
||||
Name: string;
|
||||
Email: string;
|
||||
IsActive: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LinkedServer {
|
||||
Id: string;
|
||||
Url: string;
|
||||
Name: string;
|
||||
SystemId: string;
|
||||
AccessKey: string;
|
||||
LocalAddress: string;
|
||||
UserType: string;
|
||||
SupporterKey: string;
|
||||
}
|
||||
|
||||
export interface LocalUserAuthExchangeResponse {
|
||||
LocalUserId: string;
|
||||
AccessToken: string;
|
||||
}
|
||||
|
||||
export interface EmbyConnectOptions {
|
||||
ClientIP?: string;
|
||||
DeviceId?: string;
|
||||
}
|
||||
|
||||
const EMBY_CONNECT_URL = 'https://connect.emby.media';
|
||||
|
||||
class EmbyConnectAPI extends ExternalAPI {
|
||||
private ClientIP?: string;
|
||||
private DeviceId?: string;
|
||||
|
||||
constructor(options: EmbyConnectOptions = {}) {
|
||||
super(
|
||||
EMBY_CONNECT_URL,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'X-Application': `Jellyseerr/${getAppVersion()}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...(getSettings().main.mediaServerType === MediaServerType.EMBY &&
|
||||
{}),
|
||||
},
|
||||
}
|
||||
);
|
||||
this.ClientIP = options.ClientIP;
|
||||
this.DeviceId = options.DeviceId;
|
||||
}
|
||||
|
||||
public async authenticateConnectUser(Email?: string, Password?: string) {
|
||||
logger.debug(`Attempting to authenticate via EmbyConnect with email:`, {
|
||||
Email,
|
||||
});
|
||||
|
||||
const connectAuthResponse = await this.getConnectUserAccessToken(
|
||||
Email,
|
||||
Password
|
||||
);
|
||||
|
||||
const linkedServers = await this.getValidServers(
|
||||
connectAuthResponse.User.Id,
|
||||
connectAuthResponse.AccessToken
|
||||
);
|
||||
|
||||
const matchingServer = this.findMatchingServer(linkedServers);
|
||||
|
||||
const localUserExchangeResponse = await this.localAuthExchange(
|
||||
matchingServer.AccessKey,
|
||||
connectAuthResponse.User.Id,
|
||||
this.DeviceId
|
||||
);
|
||||
|
||||
return {
|
||||
User: {
|
||||
Name: connectAuthResponse.User.Name,
|
||||
Email: connectAuthResponse.User.Email,
|
||||
ServerId: matchingServer.SystemId,
|
||||
ServerName: matchingServer.Name,
|
||||
Id: localUserExchangeResponse.LocalUserId,
|
||||
Configuration: {
|
||||
GroupedFolders: [],
|
||||
},
|
||||
Policy: {
|
||||
IsAdministrator: false, // This requires an additional EmbyServer API call, skipping for now
|
||||
},
|
||||
},
|
||||
AccessToken: localUserExchangeResponse.AccessToken,
|
||||
} as JellyfinLoginResponse;
|
||||
}
|
||||
|
||||
private async getConnectUserAccessToken(
|
||||
Email?: string,
|
||||
Password?: string
|
||||
): Promise<ConnectAuthResponse> {
|
||||
try {
|
||||
const response = await this.post<ConnectAuthResponse>(
|
||||
'/service/user/authenticate',
|
||||
{ nameOrEmail: Email, rawpw: Password },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.debug(`Failed to authenticate using EmbyConnect:`, {
|
||||
label: 'EmbyConnect API',
|
||||
ip: this.ClientIP,
|
||||
error: e.message,
|
||||
});
|
||||
throw new ApiError(
|
||||
e.cause?.status ?? 401,
|
||||
ApiErrorCode.InvalidCredentials
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getValidServers(
|
||||
ConnectUserId: string,
|
||||
AccessToken: string
|
||||
): Promise<LinkedServer[]> {
|
||||
try {
|
||||
const response = await this.get<LinkedServer[]>(`/service/servers`, {
|
||||
params: { userId: ConnectUserId },
|
||||
headers: {
|
||||
'X-Connect-UserToken': AccessToken,
|
||||
},
|
||||
});
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(`Failed to retrieve EmbyConnect user server list: `, {
|
||||
label: 'EmbyConnect API',
|
||||
ip: this.ClientIP,
|
||||
error: e.message,
|
||||
});
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||
}
|
||||
}
|
||||
|
||||
private findMatchingServer(linkedEmbyServers: LinkedServer[]): LinkedServer {
|
||||
const settings = getSettings();
|
||||
const matchingServer = linkedEmbyServers.find(
|
||||
(server) => server.SystemId === settings.jellyfin.serverId
|
||||
);
|
||||
|
||||
if (!matchingServer) {
|
||||
throw new Error(
|
||||
`No matching linked Emby server found for serverId: ${settings.jellyfin.serverId}`
|
||||
);
|
||||
}
|
||||
|
||||
return matchingServer;
|
||||
}
|
||||
|
||||
private async localAuthExchange(
|
||||
accessKey: string,
|
||||
userId: string,
|
||||
deviceId?: string
|
||||
): Promise<LocalUserAuthExchangeResponse> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
format: 'json',
|
||||
ConnectUserId: userId,
|
||||
'X-Emby-Client': 'Jellyseerr',
|
||||
'X-Emby-Device-Id': deviceId ?? uniqueId(),
|
||||
'X-Emby-Client-Version': getAppVersion(),
|
||||
'X-Emby-Device-Name': 'Jellyseerr',
|
||||
'X-Emby-Token': accessKey,
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${getHostname()}/emby/Connect/Exchange?${params}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText, { cause: response });
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (e) {
|
||||
logger.debug('Failed local user auth exchange', e.cause);
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default EmbyConnectAPI;
|
||||
@@ -3,6 +3,7 @@ import type { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import axios from 'axios';
|
||||
import rateLimit from 'axios-rate-limit';
|
||||
import type NodeCache from 'node-cache';
|
||||
import querystring from 'querystring';
|
||||
|
||||
// 5 minute default TTL (in seconds)
|
||||
const DEFAULT_TTL = 300;
|
||||
@@ -82,6 +83,7 @@ class ExternalAPI {
|
||||
): Promise<T> {
|
||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||
config: config?.params,
|
||||
headers: config?.headers,
|
||||
...(data ? { data } : {}),
|
||||
});
|
||||
|
||||
@@ -90,7 +92,16 @@ class ExternalAPI {
|
||||
return cachedItem;
|
||||
}
|
||||
|
||||
const response = await this.axios.post<T>(endpoint, data, config);
|
||||
const isFormUrlEncoded = (
|
||||
config?.headers?.['Content-Type'] as string
|
||||
)?.includes('application/x-www-form-urlencoded');
|
||||
|
||||
const body =
|
||||
data && isFormUrlEncoded
|
||||
? querystring.stringify(data as Record<string, string>)
|
||||
: data;
|
||||
|
||||
const response = await this.axios.post<T>(endpoint, body, config);
|
||||
|
||||
if (this.cache && ttl !== 0) {
|
||||
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import EmbyConnectAPI from '@server/api/embyconnect';
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
@@ -7,9 +8,11 @@ import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { ApiError } from '@server/types/error';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import * as EmailValidator from 'email-validator';
|
||||
|
||||
export interface JellyfinUserResponse {
|
||||
Name: string;
|
||||
Email?: string;
|
||||
ServerId: string;
|
||||
ServerName: string;
|
||||
Id: string;
|
||||
@@ -119,6 +122,7 @@ export interface JellyfinItemsReponse {
|
||||
|
||||
class JellyfinAPI extends ExternalAPI {
|
||||
private userId?: string;
|
||||
private deviceId?: string;
|
||||
private mediaServerType: MediaServerType;
|
||||
|
||||
constructor(
|
||||
@@ -150,7 +154,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
this.deviceId = deviceId ? deviceId : undefined;
|
||||
this.mediaServerType = settings.main.mediaServerType;
|
||||
}
|
||||
|
||||
@@ -173,6 +177,31 @@ class JellyfinAPI extends ExternalAPI {
|
||||
);
|
||||
};
|
||||
|
||||
if (
|
||||
getSettings().main.mediaServerType === MediaServerType.EMBY &&
|
||||
Username &&
|
||||
EmailValidator.validate(Username)
|
||||
) {
|
||||
try {
|
||||
const connectApi = new EmbyConnectAPI({
|
||||
ClientIP: ClientIP,
|
||||
DeviceId: this.deviceId,
|
||||
});
|
||||
return await connectApi.authenticateConnectUser(Username, Password);
|
||||
} catch (e) {
|
||||
// Possible local Emby user with email as username
|
||||
logger.warn(
|
||||
`Emby Connect authentication failed: ${e}, attempting local Emby server authentication`,
|
||||
{
|
||||
label: 'Jellyfin API',
|
||||
error:
|
||||
e.cause?.message ?? e.cause?.statusText ?? ApiErrorCode.Unknown,
|
||||
ip: ClientIP,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await authenticate(true);
|
||||
} catch (e) {
|
||||
@@ -245,9 +274,9 @@ class JellyfinAPI extends ExternalAPI {
|
||||
|
||||
public async getUsers(): Promise<JellyfinUserListResponse> {
|
||||
try {
|
||||
const userReponse = await this.get<JellyfinUserResponse[]>(`/Users`);
|
||||
const userResponse = await this.get<JellyfinUserResponse[]>(`/Users`);
|
||||
|
||||
return { users: userReponse };
|
||||
return { users: userResponse };
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||
@@ -260,10 +289,10 @@ class JellyfinAPI extends ExternalAPI {
|
||||
|
||||
public async getUser(): Promise<JellyfinUserResponse> {
|
||||
try {
|
||||
const userReponse = await this.get<JellyfinUserResponse>(
|
||||
const userResponse = await this.get<JellyfinUserResponse>(
|
||||
`/Users/${this.userId ?? 'Me'}`
|
||||
);
|
||||
return userReponse;
|
||||
return userResponse;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||
|
||||
@@ -42,7 +42,6 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
|
||||
user.warnings.push('userEmailRequired');
|
||||
logger.warn(`User ${user.username} has no valid email address`);
|
||||
}
|
||||
|
||||
return res.status(200).json(user);
|
||||
});
|
||||
|
||||
@@ -416,25 +415,34 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
}
|
||||
// User already exists, let's update their information
|
||||
else if (account.User.Id === user?.jellyfinUserId) {
|
||||
const serverType =
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? ServerType.JELLYFIN
|
||||
: ServerType.EMBY;
|
||||
|
||||
const userType =
|
||||
serverType === ServerType.JELLYFIN ? UserType.JELLYFIN : UserType.EMBY;
|
||||
|
||||
logger.info(
|
||||
`Found matching ${
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? ServerType.JELLYFIN
|
||||
: ServerType.EMBY
|
||||
} user; updating user with ${
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? ServerType.JELLYFIN
|
||||
: ServerType.EMBY
|
||||
}`,
|
||||
`Found matching ${serverType} user; updating user with ${serverType}`,
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUsername: account.User.Name,
|
||||
}
|
||||
);
|
||||
|
||||
user.userType = userType;
|
||||
user.avatar = getUserAvatarUrl(user);
|
||||
user.jellyfinUsername = account.User.Name;
|
||||
|
||||
if (
|
||||
account.User.Email !== undefined &&
|
||||
user.email !== account.User.Email
|
||||
) {
|
||||
user.email = account.User.Email;
|
||||
}
|
||||
|
||||
if (user.username === account.User.Name) {
|
||||
user.username = '';
|
||||
}
|
||||
@@ -455,34 +463,59 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
message: 'Access denied.',
|
||||
});
|
||||
} else if (!user) {
|
||||
logger.info(
|
||||
'Sign-in attempt from Jellyfin user with access to the media server; creating new Jellyseerr user',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUsername: account.User.Name,
|
||||
}
|
||||
);
|
||||
|
||||
user = new User({
|
||||
email: body.email,
|
||||
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);
|
||||
|
||||
//initialize Jellyfin/Emby users with local login
|
||||
const passedExplicitPassword = body.password && body.password.length > 0;
|
||||
if (passedExplicitPassword) {
|
||||
await user.setPassword(body.password ?? '');
|
||||
// Handle Emby Connect user with unlinked local account
|
||||
if (
|
||||
settings.main.mediaServerType === MediaServerType.EMBY &&
|
||||
account.User.Email &&
|
||||
account.User.Email.trim() !== ''
|
||||
) {
|
||||
user = await userRepository.findOne({
|
||||
where: { email: account.User.Email },
|
||||
});
|
||||
}
|
||||
|
||||
if (user) {
|
||||
logger.info(
|
||||
`Sign in attempt from EmbyConnect user with access to the media server, linking users`
|
||||
);
|
||||
user.avatar = getUserAvatarUrl(user);
|
||||
user.jellyfinUserId = account.User.Id;
|
||||
user.userType = UserType.EMBY;
|
||||
user.username = account.User.Name;
|
||||
await userRepository.save(user);
|
||||
|
||||
// No user, create new
|
||||
} else {
|
||||
logger.info(
|
||||
'Sign-in attempt from Jellyfin/Emby user with access to the media server; creating new Jellyseerr user',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUsername: account.User.Name,
|
||||
}
|
||||
);
|
||||
|
||||
user = new User({
|
||||
email: body.email,
|
||||
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);
|
||||
|
||||
//initialize Jellyfin/Emby users with local login
|
||||
const passedExplicitPassword =
|
||||
body.password && body.password.length > 0;
|
||||
if (passedExplicitPassword) {
|
||||
await user.setPassword(body.password ?? '');
|
||||
}
|
||||
await userRepository.save(user);
|
||||
}
|
||||
await userRepository.save(user);
|
||||
}
|
||||
|
||||
if (user && user.jellyfinUserId) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import * as Yup from 'yup';
|
||||
const messages = defineMessages('components.Login', {
|
||||
loginwithapp: 'Login with {appName}',
|
||||
username: 'Username',
|
||||
email: 'Email Address',
|
||||
password: 'Password',
|
||||
validationusernamerequired: 'Username required',
|
||||
validationpasswordrequired: 'Password required',
|
||||
@@ -125,7 +126,9 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.username)}
|
||||
placeholder={`${intl.formatMessage(
|
||||
messages.email
|
||||
)} / ${intl.formatMessage(messages.username)}`}
|
||||
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||
data-form-type="username"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user