fix(avatar): fix avatar cache busting by using avatarVersion (#1537)

* 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
This commit is contained in:
fallenbagel
2025-03-28 06:02:34 +08:00
committed by GitHub
parent 7438042757
commit 29034b350d
6 changed files with 232 additions and 13 deletions

View File

@@ -8,10 +8,12 @@ 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);
@@ -31,6 +33,79 @@ async function initAvatarImageProxy() {
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}$/)) {
@@ -46,6 +121,10 @@ router.get('/:jellyfinUserId', async (req, res) => {
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 },
});
@@ -55,13 +134,7 @@ router.get('/:jellyfinUserId', async (req, res) => {
size: 200,
});
const setttings = getSettings();
const jellyfinAvatarUrl =
setttings.main.mediaServerType === MediaServerType.JELLYFIN
? `${getHostname()}/UserImage?UserId=${req.params.jellyfinUserId}`
: `${getHostname()}/Users/${
req.params.jellyfinUserId
}/Images/Primary?quality=90`;
const jellyfinAvatarUrl = getJellyfinAvatarUrl(req.params.jellyfinUserId);
let imageData = await avatarImageCache.getImage(
jellyfinAvatarUrl,
@@ -73,10 +146,15 @@ router.get('/:jellyfinUserId', async (req, res) => {
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',
});