Compare commits

..

1 Commits

Author SHA1 Message Date
gauthier-th
f8db770bf3 fix(api): catch error when watchlist item doesn't exist anymore 2025-09-16 11:20:58 +02:00
7 changed files with 63 additions and 338 deletions

3
.gitignore vendored
View File

@@ -71,6 +71,3 @@ tsconfig.tsbuildinfo
# Config Cache Directory
config/cache
# Mise
mise.toml

View File

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

View File

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

View File

@@ -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}`,

View File

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

View File

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

View File

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