Skip to content

Commit

Permalink
fix: cache Jellyfin/Emby avatars from API (#1045)
Browse files Browse the repository at this point in the history
* fix: cache Jellyfin/Emby avatars from API

Previously, avatars were cached using image links from Jellyfin/Emby. Now, avatar images are
obtained directly from the API to avoid some configuration bugs.

* fix: update avatar on new login
  • Loading branch information
gauthier-th authored Oct 24, 2024
1 parent 32e0b12 commit 0bbcfcb
Show file tree
Hide file tree
Showing 6 changed files with 73 additions and 71 deletions.
21 changes: 18 additions & 3 deletions server/lib/imageproxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,15 @@ class ImageProxy {
private cacheVersion;
private key;
private baseUrl;
private headers: HeadersInit | null = null;

constructor(
key: string,
baseUrl: string,
options: {
cacheVersion?: number;
rateLimitOptions?: RateLimitOptions;
headers?: HeadersInit;
} = {}
) {
this.cacheVersion = options.cacheVersion ?? 1;
Expand All @@ -155,9 +157,13 @@ class ImageProxy {
} else {
this.fetch = fetch;
}
this.headers = options.headers || null;
}

public async getImage(path: string): Promise<ImageResponse> {
public async getImage(
path: string,
fallbackPath?: string
): Promise<ImageResponse> {
const cacheKey = this.getCacheKey(path);

const imageResponse = await this.get(cacheKey);
Expand All @@ -166,7 +172,11 @@ class ImageProxy {
const newImage = await this.set(path, cacheKey);

if (!newImage) {
throw new Error('Failed to load image');
if (fallbackPath) {
return await this.getImage(fallbackPath);
} else {
throw new Error('Failed to load image');
}
}

return newImage;
Expand Down Expand Up @@ -247,7 +257,12 @@ class ImageProxy {
: '/'
: '') +
(path.startsWith('/') ? path.slice(1) : path);
const response = await this.fetch(href);
const response = await this.fetch(href, {
headers: this.headers || undefined,
});
if (!response.ok) {
return null;
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);

Expand Down
45 changes: 4 additions & 41 deletions server/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { startJobs } from '@server/job/schedule';
import ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
Expand All @@ -15,7 +14,6 @@ import { ApiError } from '@server/types/error';
import { getHostname } from '@server/utils/getHostname';
import * as EmailValidator from 'email-validator';
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import net from 'net';

const authRoutes = Router();
Expand Down Expand Up @@ -328,12 +326,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: account.User.PrimaryImageTag
? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email || account.User.Name, {
default: 'mm',
size: 200,
}),
avatar: `/avatarproxy/${account.User.Id}`,
userType: UserType.EMBY,
});

Expand All @@ -347,12 +340,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: account.User.PrimaryImageTag
? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email || account.User.Name, {
default: 'mm',
size: 200,
}),
avatar: `/avatarproxy/${account.User.Id}`,
userType: UserType.JELLYFIN,
});

Expand Down Expand Up @@ -401,27 +389,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUsername: account.User.Name,
}
);
// Update the users avatar with their jellyfin profile pic (incase it changed)
if (account.User.PrimaryImageTag) {
const avatar = `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
if (avatar !== user.avatar) {
const avatarProxy = new ImageProxy('avatar', '');
avatarProxy.clearCachedImage(user.avatar);
}
user.avatar = avatar;
} else {
const avatar = gravatarUrl(user.email || account.User.Name, {
default: 'mm',
size: 200,
});

if (avatar !== user.avatar) {
const avatarProxy = new ImageProxy('avatar', '');
avatarProxy.clearCachedImage(user.avatar);
}

user.avatar = avatar;
}
user.avatar = `/avatarproxy/${account.User.Id}`;
user.jellyfinUsername = account.User.Name;

if (user.username === account.User.Name) {
Expand Down Expand Up @@ -459,12 +427,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
permissions: settings.main.defaultPermissions,
avatar: account.User.PrimaryImageTag
? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email || account.User.Name, {
default: 'mm',
size: 200,
}),
avatar: `/avatarproxy/${account.User.Id}`,
userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
Expand Down
59 changes: 47 additions & 12 deletions server/routes/avatarproxy.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,39 @@
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import ImageProxy from '@server/lib/imageproxy';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { getAppVersion } from '@server/utils/appVersion';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';

const router = Router();

const avatarImageProxy = new ImageProxy('avatar', '');
// Proxy avatar images
router.get('/*', async (req, res) => {
let imagePath = '';
let _avatarImageProxy: ImageProxy | null = null;
async function initAvatarImageProxy() {
if (!_avatarImageProxy) {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
where: { id: 1 },
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' },
});
const deviceId = admin?.jellyfinDeviceId;
const authToken = getSettings().jellyfin.apiKey;
_avatarImageProxy = new ImageProxy('avatar', '', {
headers: {
'X-Emby-Authorization': `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`,
},
});
}
return _avatarImageProxy;
}

router.get('/:jellyfinUserId', async (req, res) => {
try {
const jellyfinAvatar = req.url.match(
/(\/Users\/\w+\/Images\/Primary\/?\?tag=\w+&quality=90)$/
)?.[1];
if (!jellyfinAvatar) {
if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) {
const mediaServerType = getSettings().main.mediaServerType;
throw new Error(
`Provided URL is not ${
Expand All @@ -26,10 +44,28 @@ router.get('/*', async (req, res) => {
);
}

const imageUrl = new URL(jellyfinAvatar, getHostname());
imagePath = imageUrl.toString();
const avatarImageCache = await initAvatarImageProxy();

const imageData = await avatarImageProxy.getImage(imagePath);
const user = await getRepository(User).findOne({
where: { jellyfinUserId: req.params.jellyfinUserId },
});

const fallbackUrl = gravatarUrl(user?.email || 'none', {
default: 'mm',
size: 200,
});
const jellyfinAvatarUrl = `${getHostname()}/UserImage?UserId=${
req.params.jellyfinUserId
}`;
let imageData = await avatarImageCache.getImage(
jellyfinAvatarUrl,
fallbackUrl
);

if (imageData.meta.extension === 'json') {
// this is a 404
imageData = await avatarImageCache.getImage(fallbackUrl);
}

res.writeHead(200, {
'Content-Type': `image/${imageData.meta.extension}`,
Expand All @@ -42,7 +78,6 @@ router.get('/*', async (req, res) => {
res.end(imageData.imageBuffer);
} catch (e) {
logger.error('Failed to proxy avatar image', {
imagePath,
errorMessage: e.message,
});
}
Expand Down
5 changes: 1 addition & 4 deletions server/routes/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import fs from 'fs';
import gravatarUrl from 'gravatar-url';
import { escapeRegExp, merge, omit, set, sortBy } from 'lodash';
import { rescheduleJob } from 'node-schedule';
import path from 'path';
Expand Down Expand Up @@ -395,9 +394,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
const users = resp.users.map((user) => ({
username: user.Name,
id: user.Id,
thumb: user.PrimaryImageTag
? `/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
: gravatarUrl(user.Name, { default: 'mm', size: 200 }),
thumb: `/avatarproxy/${user.Id}`,
email: user.Name,
}));

Expand Down
7 changes: 1 addition & 6 deletions server/routes/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,12 +539,7 @@ router.post(
).toString('base64'),
email: jellyfinUser?.Name,
permissions: settings.main.defaultPermissions,
avatar: jellyfinUser?.PrimaryImageTag
? `/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
: gravatarUrl(jellyfinUser?.Name ?? '', {
default: 'mm',
size: 200,
}),
avatar: `/avatarproxy/${jellyfinUser?.Id}`,
userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
Expand Down
7 changes: 2 additions & 5 deletions src/components/Common/CachedImage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,8 @@ const CachedImage = ({ src, type, ...props }: CachedImageProps) => {
? src.replace(/^https:\/\/image\.tmdb\.org\//, '/imageproxy/')
: src;
} else if (type === 'avatar') {
// jellyfin avatar (in any)
const jellyfinAvatar = src.match(
/(\/Users\/\w+\/Images\/Primary\/?\?tag=\w+&quality=90)$/
)?.[1];
imageUrl = jellyfinAvatar ? `/avatarproxy` + jellyfinAvatar : src;
// jellyfin avatar (if any)
imageUrl = src;
} else {
return null;
}
Expand Down

0 comments on commit 0bbcfcb

Please sign in to comment.