mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
* fix(avatar): fix avatar cache busting by using avatarVersion Previously, avatar caching did not update the avatar when the remote image changed. This commit adds logic to check if the avatar was modified remotely by comparing aremote last-modified timestamp with a locally stored version (avatarVersion). If a change is detected, the cache is cleared, a new image is fetched, and avatarVersionis updated. Otherwise, the cached image is retained. * chore(db): add db migrations * refactor: refactor imagehelpers util to where its used * refactor: remove remnants from previous cache busting versions
171 lines
5.2 KiB
TypeScript
171 lines
5.2 KiB
TypeScript
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';
|
|
import { createHash } from 'node:crypto';
|
|
|
|
const router = Router();
|
|
|
|
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;
|
|
}
|
|
|
|
function getJellyfinAvatarUrl(userId: string) {
|
|
const settings = getSettings();
|
|
return settings.main.mediaServerType === MediaServerType.JELLYFIN
|
|
? `${getHostname()}/UserImage?UserId=${userId}`
|
|
: `${getHostname()}/Users/${userId}/Images/Primary?quality=90`;
|
|
}
|
|
|
|
function computeImageHash(buffer: Buffer): string {
|
|
return createHash('sha256').update(buffer).digest('hex');
|
|
}
|
|
|
|
export async function checkAvatarChanged(
|
|
user: User
|
|
): Promise<{ changed: boolean; etag?: string }> {
|
|
try {
|
|
if (!user || !user.jellyfinUserId) {
|
|
return { changed: false };
|
|
}
|
|
|
|
const jellyfinAvatarUrl = getJellyfinAvatarUrl(user.jellyfinUserId);
|
|
|
|
const headResponse = await fetch(jellyfinAvatarUrl, { method: 'HEAD' });
|
|
if (!headResponse.ok) {
|
|
return { changed: false };
|
|
}
|
|
|
|
const settings = getSettings();
|
|
let remoteVersion: string;
|
|
if (settings.main.mediaServerType === MediaServerType.JELLYFIN) {
|
|
const remoteLastModifiedStr =
|
|
headResponse.headers.get('last-modified') || '';
|
|
remoteVersion = (
|
|
Date.parse(remoteLastModifiedStr) || Date.now()
|
|
).toString();
|
|
} else if (settings.main.mediaServerType === MediaServerType.EMBY) {
|
|
remoteVersion =
|
|
headResponse.headers.get('etag')?.replace(/"/g, '') ||
|
|
Date.now().toString();
|
|
} else {
|
|
remoteVersion = Date.now().toString();
|
|
}
|
|
|
|
if (user.avatarVersion && user.avatarVersion === remoteVersion) {
|
|
return { changed: false, etag: user.avatarETag ?? undefined };
|
|
}
|
|
|
|
const avatarImageCache = await initAvatarImageProxy();
|
|
await avatarImageCache.clearCachedImage(jellyfinAvatarUrl);
|
|
const imageData = await avatarImageCache.getImage(
|
|
jellyfinAvatarUrl,
|
|
gravatarUrl(user.email || 'none', { default: 'mm', size: 200 })
|
|
);
|
|
|
|
const newHash = computeImageHash(imageData.imageBuffer);
|
|
|
|
const hasChanged = user.avatarETag !== newHash;
|
|
|
|
user.avatarVersion = remoteVersion;
|
|
if (hasChanged) {
|
|
user.avatarETag = newHash;
|
|
}
|
|
|
|
await getRepository(User).save(user);
|
|
|
|
return { changed: hasChanged, etag: newHash };
|
|
} catch (error) {
|
|
logger.error('Error checking avatar changes', {
|
|
errorMessage: error.message,
|
|
});
|
|
return { changed: false };
|
|
}
|
|
}
|
|
|
|
router.get('/:jellyfinUserId', async (req, res) => {
|
|
try {
|
|
if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) {
|
|
const mediaServerType = getSettings().main.mediaServerType;
|
|
throw new Error(
|
|
`Provided URL is not ${
|
|
mediaServerType === MediaServerType.JELLYFIN
|
|
? 'a Jellyfin'
|
|
: 'an Emby'
|
|
} avatar.`
|
|
);
|
|
}
|
|
|
|
const avatarImageCache = await initAvatarImageProxy();
|
|
|
|
const userEtag = req.headers['if-none-match'];
|
|
|
|
const versionParam = req.query.v;
|
|
|
|
const user = await getRepository(User).findOne({
|
|
where: { jellyfinUserId: req.params.jellyfinUserId },
|
|
});
|
|
|
|
const fallbackUrl = gravatarUrl(user?.email || 'none', {
|
|
default: 'mm',
|
|
size: 200,
|
|
});
|
|
|
|
const jellyfinAvatarUrl = getJellyfinAvatarUrl(req.params.jellyfinUserId);
|
|
|
|
let imageData = await avatarImageCache.getImage(
|
|
jellyfinAvatarUrl,
|
|
fallbackUrl
|
|
);
|
|
|
|
if (imageData.meta.extension === 'json') {
|
|
// this is a 404
|
|
imageData = await avatarImageCache.getImage(fallbackUrl);
|
|
}
|
|
|
|
if (userEtag && userEtag === `"${imageData.meta.etag}"` && !versionParam) {
|
|
return res.status(304).end();
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': `image/${imageData.meta.extension}`,
|
|
'Content-Length': imageData.imageBuffer.length,
|
|
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
|
|
ETag: `"${imageData.meta.etag}"`,
|
|
'OS-Cache-Key': imageData.meta.cacheKey,
|
|
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
|
|
});
|
|
|
|
res.end(imageData.imageBuffer);
|
|
} catch (e) {
|
|
logger.error('Failed to proxy avatar image', {
|
|
errorMessage: e.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
export default router;
|