mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-28 04:30:34 -05:00
Compare commits
1 Commits
preview-em
...
preview-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8db770bf3 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -71,6 +71,3 @@ tsconfig.tsbuildinfo
|
||||
|
||||
# Config Cache Directory
|
||||
config/cache
|
||||
|
||||
# Mise
|
||||
mise.toml
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
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,7 +3,6 @@ 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;
|
||||
@@ -83,7 +82,6 @@ class ExternalAPI {
|
||||
): Promise<T> {
|
||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||
config: config?.params,
|
||||
headers: config?.headers,
|
||||
...(data ? { data } : {}),
|
||||
});
|
||||
|
||||
@@ -92,16 +90,7 @@ class ExternalAPI {
|
||||
return cachedItem;
|
||||
}
|
||||
|
||||
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);
|
||||
const response = await this.axios.post<T>(endpoint, data, config);
|
||||
|
||||
if (this.cache && ttl !== 0) {
|
||||
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* 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';
|
||||
@@ -8,11 +7,9 @@ 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;
|
||||
@@ -122,7 +119,6 @@ export interface JellyfinItemsReponse {
|
||||
|
||||
class JellyfinAPI extends ExternalAPI {
|
||||
private userId?: string;
|
||||
private deviceId?: string;
|
||||
private mediaServerType: MediaServerType;
|
||||
|
||||
constructor(
|
||||
@@ -154,7 +150,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
},
|
||||
}
|
||||
);
|
||||
this.deviceId = deviceId ? deviceId : undefined;
|
||||
|
||||
this.mediaServerType = settings.main.mediaServerType;
|
||||
}
|
||||
|
||||
@@ -177,31 +173,6 @@ 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) {
|
||||
@@ -274,9 +245,9 @@ class JellyfinAPI extends ExternalAPI {
|
||||
|
||||
public async getUsers(): Promise<JellyfinUserListResponse> {
|
||||
try {
|
||||
const userResponse = await this.get<JellyfinUserResponse[]>(`/Users`);
|
||||
const userReponse = await this.get<JellyfinUserResponse[]>(`/Users`);
|
||||
|
||||
return { users: userResponse };
|
||||
return { users: userReponse };
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||
@@ -289,10 +260,10 @@ class JellyfinAPI extends ExternalAPI {
|
||||
|
||||
public async getUser(): Promise<JellyfinUserResponse> {
|
||||
try {
|
||||
const userResponse = await this.get<JellyfinUserResponse>(
|
||||
const userReponse = await this.get<JellyfinUserResponse>(
|
||||
`/Users/${this.userId ?? 'Me'}`
|
||||
);
|
||||
return userResponse;
|
||||
return userReponse;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||
|
||||
@@ -312,12 +312,25 @@ class PlexTvAPI extends ExternalAPI {
|
||||
const watchlistDetails = await Promise.all(
|
||||
(cachedWatchlist?.response.MediaContainer.Metadata ?? []).map(
|
||||
async (watchlistItem) => {
|
||||
const detailedResponse = await this.getRolling<MetadataResponse>(
|
||||
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||
{
|
||||
baseURL: 'https://discover.provider.plex.tv',
|
||||
let detailedResponse: MetadataResponse;
|
||||
try {
|
||||
detailedResponse = await this.getRolling<MetadataResponse>(
|
||||
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||
{
|
||||
baseURL: 'https://discover.provider.plex.tv',
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
if (e.response?.status === 404) {
|
||||
logger.warn(
|
||||
`Item with ratingKey ${watchlistItem.ratingKey} not found, it may have been removed from the server.`,
|
||||
{ label: 'Plex.TV Metadata API' }
|
||||
);
|
||||
return null;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
||||
|
||||
@@ -343,7 +356,9 @@ class PlexTvAPI extends ExternalAPI {
|
||||
)
|
||||
);
|
||||
|
||||
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
|
||||
const filteredList = watchlistDetails.filter(
|
||||
(detail) => detail?.tmdbId
|
||||
) as PlexWatchlistItem[];
|
||||
|
||||
return {
|
||||
offset,
|
||||
|
||||
@@ -42,6 +42,7 @@ 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);
|
||||
});
|
||||
|
||||
@@ -415,34 +416,25 @@ 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 ${serverType} user; updating user with ${serverType}`,
|
||||
`Found matching ${
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? ServerType.JELLYFIN
|
||||
: ServerType.EMBY
|
||||
} user; updating user with ${
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? ServerType.JELLYFIN
|
||||
: ServerType.EMBY
|
||||
}`,
|
||||
{
|
||||
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 = '';
|
||||
}
|
||||
@@ -463,59 +455,34 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
message: 'Access denied.',
|
||||
});
|
||||
} else if (!user) {
|
||||
// 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,
|
||||
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,
|
||||
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);
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (user && user.jellyfinUserId) {
|
||||
|
||||
@@ -14,7 +14,6 @@ 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',
|
||||
@@ -126,9 +125,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={`${intl.formatMessage(
|
||||
messages.email
|
||||
)} / ${intl.formatMessage(messages.username)}`}
|
||||
placeholder={intl.formatMessage(messages.username)}
|
||||
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||
data-form-type="username"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user