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 axios from 'axios'; 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 || 'BOT_seerr'; const authToken = getSettings().jellyfin.apiKey; _avatarImageProxy = new ImageProxy('avatar', '', { headers: { 'X-Emby-Authorization': `MediaBrowser Client="Seerr", Device="Seerr", 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); let headResponse; try { headResponse = await axios.head(jellyfinAvatarUrl); if (headResponse.status !== 200) { return { changed: false }; } } catch (error) { return { changed: false }; } const settings = getSettings(); let remoteVersion: string; if (settings.main.mediaServerType === MediaServerType.JELLYFIN) { const remoteLastModifiedStr = headResponse.headers['last-modified'] || ''; remoteVersion = ( Date.parse(remoteLastModifiedStr) || Date.now() ).toString(); } else if (settings.main.mediaServerType === MediaServerType.EMBY) { remoteVersion = headResponse.headers['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;