Compare commits

...

9 Commits

Author SHA1 Message Date
JoaquinOlivero
374eaee616 fix: format 2024-08-13 22:35:42 +00:00
JoaquinOlivero
7e7a65caee refactor: use 'mime' package to determine a file's extension 2024-08-13 22:30:37 +00:00
JoaquinOlivero
7369802146 fix: requested changes 2024-08-13 22:30:04 +00:00
JoaquinOlivero
4b18817893 fix: remove unexpired unused image when user changes avatar 2024-08-13 22:29:53 +00:00
JoaquinOlivero
5fc5c2b4a3 fix: set correct src url for cached image 2024-08-13 22:29:38 +00:00
JoaquinOlivero
f8ae87f2b5 fix: show the correct avatar in the list of available users in advanced request 2024-08-13 22:29:24 +00:00
JoaquinOlivero
894799d626 fix: set avatar image url 2024-08-13 22:29:01 +00:00
JoaquinOlivero
98b388b999 fix: extract keys 2024-08-13 22:28:48 +00:00
JoaquinOlivero
6ab41a5ec2 refactor: proxy and cache user avatar images 2024-08-13 22:28:38 +00:00
23 changed files with 238 additions and 92 deletions

View File

@@ -14,7 +14,6 @@ module.exports = {
remotePatterns: [ remotePatterns: [
{ hostname: 'gravatar.com' }, { hostname: 'gravatar.com' },
{ hostname: 'image.tmdb.org' }, { hostname: 'image.tmdb.org' },
{ hostname: '*', protocol: 'https' },
], ],
}, },
webpack(config) { webpack(config) {

View File

@@ -2775,6 +2775,15 @@ paths:
imageCount: imageCount:
type: number type: number
example: 123 example: 123
avatar:
type: object
properties:
size:
type: number
example: 123456
imageCount:
type: number
example: 123
apiCaches: apiCaches:
type: array type: array
items: items:

View File

@@ -62,6 +62,7 @@
"formik": "^2.4.6", "formik": "^2.4.6",
"gravatar-url": "3.1.0", "gravatar-url": "3.1.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"mime": "3",
"next": "^14.2.4", "next": "^14.2.4",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"node-gyp": "9.3.1", "node-gyp": "9.3.1",
@@ -119,6 +120,7 @@
"@types/express": "4.17.17", "@types/express": "4.17.17",
"@types/express-session": "1.17.6", "@types/express-session": "1.17.6",
"@types/lodash": "4.14.191", "@types/lodash": "4.14.191",
"@types/mime": "3",
"@types/node": "20.14.8", "@types/node": "20.14.8",
"@types/node-schedule": "2.1.0", "@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7", "@types/nodemailer": "6.4.7",

25
pnpm-lock.yaml generated
View File

@@ -98,6 +98,9 @@ importers:
lodash: lodash:
specifier: 4.17.21 specifier: 4.17.21
version: 4.17.21 version: 4.17.21
mime:
specifier: '3'
version: 3.0.0
next: next:
specifier: ^14.2.4 specifier: ^14.2.4
version: 14.2.4(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 14.2.4(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -264,6 +267,9 @@ importers:
'@types/lodash': '@types/lodash':
specifier: 4.14.191 specifier: 4.14.191
version: 4.14.191 version: 4.14.191
'@types/mime':
specifier: '3'
version: 3.0.4
'@types/node': '@types/node':
specifier: 20.14.8 specifier: 20.14.8
version: 20.14.8 version: 20.14.8
@@ -2848,6 +2854,9 @@ packages:
'@types/mime@1.3.5': '@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/mime@3.0.4':
resolution: {integrity: sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==}
'@types/minimatch@3.0.5': '@types/minimatch@3.0.5':
resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}
@@ -10836,7 +10845,7 @@ snapshots:
nopt: 5.0.0 nopt: 5.0.0
npmlog: 5.0.1 npmlog: 5.0.1
rimraf: 3.0.2 rimraf: 3.0.2
semver: 7.6.2 semver: 7.3.8
tar: 6.2.1 tar: 6.2.1
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
@@ -10911,13 +10920,13 @@ snapshots:
'@npmcli/fs@1.1.1': '@npmcli/fs@1.1.1':
dependencies: dependencies:
'@gar/promisify': 1.1.3 '@gar/promisify': 1.1.3
semver: 7.6.2 semver: 7.3.8
optional: true optional: true
'@npmcli/fs@2.1.2': '@npmcli/fs@2.1.2':
dependencies: dependencies:
'@gar/promisify': 1.1.3 '@gar/promisify': 1.1.3
semver: 7.6.2 semver: 7.3.8
'@npmcli/move-file@1.1.2': '@npmcli/move-file@1.1.2':
dependencies: dependencies:
@@ -12326,7 +12335,7 @@ snapshots:
read-pkg: 5.2.0 read-pkg: 5.2.0
registry-auth-token: 5.0.2 registry-auth-token: 5.0.2
semantic-release: 19.0.5(encoding@0.1.13) semantic-release: 19.0.5(encoding@0.1.13)
semver: 7.6.2 semver: 7.3.8
tempy: 1.0.1 tempy: 1.0.1
'@semantic-release/release-notes-generator@10.0.3(semantic-release@19.0.5(encoding@0.1.13))': '@semantic-release/release-notes-generator@10.0.3(semantic-release@19.0.5(encoding@0.1.13))':
@@ -12670,6 +12679,8 @@ snapshots:
'@types/mime@1.3.5': {} '@types/mime@1.3.5': {}
'@types/mime@3.0.4': {}
'@types/minimatch@3.0.5': {} '@types/minimatch@3.0.5': {}
'@types/minimist@1.2.5': {} '@types/minimist@1.2.5': {}
@@ -12887,7 +12898,7 @@ snapshots:
debug: 4.3.5(supports-color@8.1.1) debug: 4.3.5(supports-color@8.1.1)
globby: 11.1.0 globby: 11.1.0
is-glob: 4.0.3 is-glob: 4.0.3
semver: 7.6.2 semver: 7.3.8
tsutils: 3.21.0(typescript@4.9.5) tsutils: 3.21.0(typescript@4.9.5)
optionalDependencies: optionalDependencies:
typescript: 4.9.5 typescript: 4.9.5
@@ -17269,7 +17280,7 @@ snapshots:
nopt: 5.0.0 nopt: 5.0.0
npmlog: 6.0.2 npmlog: 6.0.2
rimraf: 3.0.2 rimraf: 3.0.2
semver: 7.6.2 semver: 7.3.8
tar: 6.2.1 tar: 6.2.1
which: 2.0.2 which: 2.0.2
transitivePeerDependencies: transitivePeerDependencies:
@@ -17348,7 +17359,7 @@ snapshots:
dependencies: dependencies:
hosted-git-info: 4.1.0 hosted-git-info: 4.1.0
is-core-module: 2.14.0 is-core-module: 2.14.0
semver: 7.6.2 semver: 7.3.8
validate-npm-package-license: 3.0.4 validate-npm-package-license: 3.0.4
normalize-path@3.0.0: {} normalize-path@3.0.0: {}

View File

@@ -19,6 +19,7 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import clearCookies from '@server/middleware/clearcookies'; import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes'; import routes from '@server/routes';
import avatarproxy from '@server/routes/avatarproxy';
import imageproxy from '@server/routes/imageproxy'; import imageproxy from '@server/routes/imageproxy';
import { getAppVersion } from '@server/utils/appVersion'; import { getAppVersion } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag'; import restartFlag from '@server/utils/restartFlag';
@@ -202,6 +203,7 @@ app
// Do not set cookies so CDNs can cache them // Do not set cookies so CDNs can cache them
server.use('/imageproxy', clearCookies, imageproxy); server.use('/imageproxy', clearCookies, imageproxy);
server.use('/avatarproxy', clearCookies, avatarproxy);
server.get('*', (req, res) => handle(req, res)); server.get('*', (req, res) => handle(req, res));
server.use( server.use(

View File

@@ -58,7 +58,7 @@ export interface CacheItem {
export interface CacheResponse { export interface CacheResponse {
apiCaches: CacheItem[]; apiCaches: CacheItem[];
imageCache: Record<'tmdb', { size: number; imageCount: number }>; imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
} }
export interface StatusResponse { export interface StatusResponse {

View File

@@ -227,6 +227,9 @@ export const startJobs = (): void => {
}); });
// Clean TMDB image cache // Clean TMDB image cache
ImageProxy.clearCache('tmdb'); ImageProxy.clearCache('tmdb');
// Clean users avatar image cache
ImageProxy.clearCache('avatar');
}), }),
}); });

View File

@@ -3,6 +3,7 @@ import type { RateLimitOptions } from '@server/utils/rateLimit';
import rateLimit from '@server/utils/rateLimit'; import rateLimit from '@server/utils/rateLimit';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { promises } from 'fs'; import { promises } from 'fs';
import mime from 'mime/lite';
import path, { join } from 'path'; import path, { join } from 'path';
type ImageResponse = { type ImageResponse = {
@@ -11,7 +12,7 @@ type ImageResponse = {
curRevalidate: number; curRevalidate: number;
isStale: boolean; isStale: boolean;
etag: string; etag: string;
extension: string; extension: string | null;
cacheKey: string; cacheKey: string;
cacheMiss: boolean; cacheMiss: boolean;
}; };
@@ -27,6 +28,7 @@ class ImageProxy {
let deletedImages = 0; let deletedImages = 0;
const cacheDirectory = path.join(baseCacheDirectory, key); const cacheDirectory = path.join(baseCacheDirectory, key);
try {
const files = await promises.readdir(cacheDirectory); const files = await promises.readdir(cacheDirectory);
for (const file of files) { for (const file of files) {
@@ -42,14 +44,29 @@ class ImageProxy {
const now = Date.now(); const now = Date.now();
if (now > expireAt) { if (now > expireAt) {
await promises.rm(path.join(filePath, imageFile)); await promises.rm(path.join(filePath), {
recursive: true,
});
deletedImages += 1; deletedImages += 1;
} }
} }
} }
} }
} catch (e) {
if (e.code === 'ENOENT') {
logger.error('Directory not found', {
label: 'Image Cache',
message: e.message,
});
} else {
logger.error('Failed to read directory', {
label: 'Image Cache',
message: e.message,
});
}
}
logger.info(`Cleared ${deletedImages} stale image(s) from cache`, { logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, {
label: 'Image Cache', label: 'Image Cache',
}); });
} }
@@ -69,6 +86,7 @@ class ImageProxy {
} }
private static async getDirectorySize(dir: string): Promise<number> { private static async getDirectorySize(dir: string): Promise<number> {
try {
const files = await promises.readdir(dir, { const files = await promises.readdir(dir, {
withFileTypes: true, withFileTypes: true,
}); });
@@ -90,12 +108,27 @@ class ImageProxy {
return (await Promise.all(paths)) return (await Promise.all(paths))
.flat(Infinity) .flat(Infinity)
.reduce((i, size) => i + size, 0); .reduce((i, size) => i + size, 0);
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
}
}
return 0;
} }
private static async getImageCount(dir: string) { private static async getImageCount(dir: string) {
try {
const files = await promises.readdir(dir); const files = await promises.readdir(dir);
return files.length; return files.length;
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
}
}
return 0;
} }
private fetch: typeof fetch; private fetch: typeof fetch;
@@ -147,6 +180,27 @@ class ImageProxy {
return imageResponse; return imageResponse;
} }
public async clearCachedImage(path: string) {
// find cacheKey
const cacheKey = this.getCacheKey(path);
try {
const directory = join(this.getCacheDirectory(), cacheKey);
const files = await promises.readdir(directory);
await promises.rm(directory, { recursive: true });
logger.info(`Cleared ${files[0]} image from cache 'avatar'`, {
label: 'Image Cache',
});
} catch (e) {
logger.error('Failed to clear cached image', {
label: 'Image Cache',
message: e.message,
});
}
}
private async get(cacheKey: string): Promise<ImageResponse | null> { private async get(cacheKey: string): Promise<ImageResponse | null> {
try { try {
const directory = join(this.getCacheDirectory(), cacheKey); const directory = join(this.getCacheDirectory(), cacheKey);
@@ -187,16 +241,25 @@ class ImageProxy {
const directory = join(this.getCacheDirectory(), cacheKey); const directory = join(this.getCacheDirectory(), cacheKey);
const href = const href =
this.baseUrl + this.baseUrl +
(this.baseUrl.endsWith('/') ? '' : '/') + (this.baseUrl.length > 0
? this.baseUrl.endsWith('/')
? ''
: '/'
: '') +
(path.startsWith('/') ? path.slice(1) : path); (path.startsWith('/') ? path.slice(1) : path);
const response = await this.fetch(href); const response = await this.fetch(href);
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
const extension = path.split('.').pop() ?? ''; const extension = mime.getExtension(
const maxAge = Number( response.headers.get('content-type') ?? ''
);
let maxAge = Number(
(response.headers.get('cache-control') ?? '0').split('=')[1] (response.headers.get('cache-control') ?? '0').split('=')[1]
); );
if (!maxAge) maxAge = 86400;
const expireAt = Date.now() + maxAge * 1000; const expireAt = Date.now() + maxAge * 1000;
const etag = (response.headers.get('etag') ?? '').replace(/"/g, ''); const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
@@ -232,7 +295,7 @@ class ImageProxy {
private async writeToCacheDir( private async writeToCacheDir(
dir: string, dir: string,
extension: string, extension: string | null,
maxAge: number, maxAge: number,
expireAt: number, expireAt: number,
buffer: Buffer, buffer: Buffer,

View File

@@ -6,6 +6,7 @@ import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User'; import { User } from '@server/entity/User';
import { startJobs } from '@server/job/schedule'; import { startJobs } from '@server/job/schedule';
import ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
@@ -376,12 +377,23 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
); );
// Update the users avatar with their jellyfin profile pic (incase it changed) // Update the users avatar with their jellyfin profile pic (incase it changed)
if (account.User.PrimaryImageTag) { if (account.User.PrimaryImageTag) {
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; const avatar = `${jellyfinHost}/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 { } else {
user.avatar = gravatarUrl(user.email || account.User.Name, { const avatar = gravatarUrl(user.email || account.User.Name, {
default: 'mm', default: 'mm',
size: 200, size: 200,
}); });
if (avatar !== user.avatar) {
const avatarProxy = new ImageProxy('avatar', '');
avatarProxy.clearCachedImage(user.avatar);
}
user.avatar = avatar;
} }
user.jellyfinUsername = account.User.Name; user.jellyfinUsername = account.User.Name;

View File

@@ -0,0 +1,31 @@
import ImageProxy from '@server/lib/imageproxy';
import logger from '@server/logger';
import { Router } from 'express';
const router = Router();
const avatarImageProxy = new ImageProxy('avatar', '');
// Proxy avatar images
router.get('/*', async (req, res) => {
const imagePath = req.url.startsWith('/') ? req.url.slice(1) : req.url;
try {
const imageData = await avatarImageProxy.getImage(imagePath);
res.writeHead(200, {
'Content-Type': `image/${imageData.meta.extension}`,
'Content-Length': imageData.imageBuffer.length,
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
'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', {
imagePath,
errorMessage: e.message,
});
}
});
export default router;

View File

@@ -746,11 +746,13 @@ settingsRoutes.get('/cache', async (_req, res) => {
})); }));
const tmdbImageCache = await ImageProxy.getImageStats('tmdb'); const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
const avatarImageCache = await ImageProxy.getImageStats('avatar');
return res.status(200).json({ return res.status(200).json({
apiCaches, apiCaches,
imageCache: { imageCache: {
tmdb: tmdbImageCache, tmdb: tmdbImageCache,
avatar: avatarImageCache,
}, },
}); });
}); });

View File

@@ -1,4 +1,5 @@
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import Modal from '@app/components/Common/Modal'; import Modal from '@app/components/Common/Modal';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
@@ -6,7 +7,6 @@ import { Menu, Transition } from '@headlessui/react';
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import type { default as IssueCommentType } from '@server/entity/IssueComment'; import type { default as IssueCommentType } from '@server/entity/IssueComment';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { FormattedRelativeTime, useIntl } from 'react-intl'; import { FormattedRelativeTime, useIntl } from 'react-intl';
@@ -88,8 +88,8 @@ const IssueComment = ({
</Modal> </Modal>
</Transition> </Transition>
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}> <Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
<Image <CachedImage
src={comment.user.avatar} src={`/avatarproxy/${comment.user.avatar}`}
alt="" alt=""
className="h-10 w-10 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105" className="h-10 w-10 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
width={40} width={40}

View File

@@ -29,7 +29,6 @@ import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config'; import getConfig from 'next/config';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
@@ -289,10 +288,10 @@ const IssueDetails = () => {
} }
className="group ml-1 inline-flex h-full items-center xl:ml-1.5" className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
> >
<Image <CachedImage
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6" src={`/avatarproxy/${issueData.createdBy.avatar}`}
src={issueData.createdBy.avatar}
alt="" alt=""
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
width={20} width={20}
height={20} height={20}
/> />

View File

@@ -1,3 +1,4 @@
import CachedImage from '@app/components/Common/CachedImage';
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay'; import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
@@ -7,7 +8,6 @@ import {
ClockIcon, ClockIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { CogIcon, UserIcon } from '@heroicons/react/24/solid'; import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
import Image from 'next/image';
import type { LinkProps } from 'next/link'; import type { LinkProps } from 'next/link';
import Link from 'next/link'; import Link from 'next/link';
import { forwardRef, Fragment } from 'react'; import { forwardRef, Fragment } from 'react';
@@ -56,9 +56,9 @@ const UserDropdown = () => {
className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500" className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500"
data-testid="user-menu" data-testid="user-menu"
> >
<Image <CachedImage
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10" className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user?.avatar || ''} src={user && user.avatar ? `/avatarproxy/${user.avatar}` : ''}
alt="" alt=""
width={40} width={40}
height={40} height={40}
@@ -79,9 +79,9 @@ const UserDropdown = () => {
<div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur"> <div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur">
<div className="flex flex-col space-y-4 px-4 py-4"> <div className="flex flex-col space-y-4 px-4 py-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Image <CachedImage
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10" className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user?.avatar || ''} src={user && user.avatar ? `/avatarproxy/${user.avatar}` : ''}
alt="" alt=""
width={40} width={40}
height={40} height={40}

View File

@@ -1,4 +1,5 @@
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import ConfirmButton from '@app/components/Common/ConfirmButton'; import ConfirmButton from '@app/components/Common/ConfirmButton';
import SlideOver from '@app/components/Common/SlideOver'; import SlideOver from '@app/components/Common/SlideOver';
import Tooltip from '@app/components/Common/Tooltip'; import Tooltip from '@app/components/Common/Tooltip';
@@ -27,7 +28,6 @@ import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import getConfig from 'next/config'; import getConfig from 'next/config';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
@@ -355,8 +355,8 @@ const ManageSlideOver = ({
key={`watch-user-${user.id}`} key={`watch-user-${user.id}`}
content={user.displayName} content={user.displayName}
> >
<Image <CachedImage
src={user.avatar} src={`/avatarproxy/${user.avatar}`}
alt={user.displayName} alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105" className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
width={32} width={32}
@@ -516,8 +516,8 @@ const ManageSlideOver = ({
key={`watch-user-${user.id}`} key={`watch-user-${user.id}`}
content={user.displayName} content={user.displayName}
> >
<Image <CachedImage
src={user.avatar} src={`/avatarproxy/${user.avatar}`}
alt={user.displayName} alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105" className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
width={32} width={32}

View File

@@ -22,7 +22,6 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common'; import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
@@ -116,8 +115,8 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
className="group flex items-center" className="group flex items-center"
> >
<span className="avatar-sm"> <span className="avatar-sm">
<Image <CachedImage
src={requestData.requestedBy.avatar} src={`/avatarproxy/${requestData.requestedBy.avatar}`}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
width={20} width={20}
@@ -390,8 +389,8 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
className="group flex items-center" className="group flex items-center"
> >
<span className="avatar-sm"> <span className="avatar-sm">
<Image <CachedImage
src={requestData.requestedBy.avatar} src={`/avatarproxy/${requestData.requestedBy.avatar}`}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
width={20} width={20}

View File

@@ -21,7 +21,6 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common'; import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
@@ -190,8 +189,8 @@ const RequestItemError = ({
className="group flex items-center truncate" className="group flex items-center truncate"
> >
<span className="avatar-sm ml-1.5"> <span className="avatar-sm ml-1.5">
<Image <CachedImage
src={requestData.requestedBy.avatar} src={`/avatarproxy/${requestData.requestedBy.avatar}`}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
width={20} width={20}
@@ -249,8 +248,8 @@ const RequestItemError = ({
className="group flex items-center truncate" className="group flex items-center truncate"
> >
<span className="avatar-sm ml-1.5"> <span className="avatar-sm ml-1.5">
<Image <CachedImage
src={requestData.modifiedBy.avatar} src={`/avatarproxy/${requestData.modifiedBy.avatar}`}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
width={20} width={20}
@@ -557,8 +556,8 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
className="group flex items-center truncate" className="group flex items-center truncate"
> >
<span className="avatar-sm ml-1.5"> <span className="avatar-sm ml-1.5">
<Image <CachedImage
src={requestData.requestedBy.avatar} src={`/avatarproxy/${requestData.requestedBy.avatar}`}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
width={20} width={20}
@@ -616,8 +615,8 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
className="group flex items-center truncate" className="group flex items-center truncate"
> >
<span className="avatar-sm ml-1.5"> <span className="avatar-sm ml-1.5">
<Image <CachedImage
src={requestData.requestedBy.avatar} src={`/avatarproxy/${requestData.requestedBy.avatar}`}
alt="" alt=""
className="avatar-sm object-cover" className="avatar-sm object-cover"
width={20} width={20}

View File

@@ -1,4 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import CachedImage from '@app/components/Common/CachedImage';
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner'; import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import type { User } from '@app/hooks/useUser'; import type { User } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
@@ -14,7 +15,6 @@ import type {
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'; import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import { hasPermission } from '@server/lib/permissions'; import { hasPermission } from '@server/lib/permissions';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import Image from 'next/image';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import Select from 'react-select'; import Select from 'react-select';
@@ -561,8 +561,8 @@ const AdvancedRequester = ({
<span className="inline-block w-full rounded-md shadow-sm"> <span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="focus:shadow-outline-blue relative w-full cursor-default rounded-md border border-gray-700 bg-gray-800 py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5"> <Listbox.Button className="focus:shadow-outline-blue relative w-full cursor-default rounded-md border border-gray-700 bg-gray-800 py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5">
<span className="flex items-center"> <span className="flex items-center">
<Image <CachedImage
src={selectedUser.avatar} src={`/avatarproxy/${selectedUser.avatar}`}
alt="" alt=""
className="h-6 w-6 flex-shrink-0 rounded-full object-cover" className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
width={24} width={24}
@@ -613,8 +613,8 @@ const AdvancedRequester = ({
selected ? 'font-semibold' : 'font-normal' selected ? 'font-semibold' : 'font-normal'
} flex items-center`} } flex items-center`}
> >
<Image <CachedImage
src={user.avatar} src={`/avatarproxy/${user.avatar}`}
alt="" alt=""
className="h-6 w-6 flex-shrink-0 rounded-full object-cover" className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
width={24} width={24}

View File

@@ -81,6 +81,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
'When enabled in settings, Jellyseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.', 'When enabled in settings, Jellyseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
imagecachecount: 'Images Cached', imagecachecount: 'Images Cached',
imagecachesize: 'Total Cache Size', imagecachesize: 'Total Cache Size',
useravatars: 'User Avatars',
} }
); );
@@ -558,6 +559,19 @@ const SettingsJobs = () => {
{formatBytes(cacheData?.imageCache.tmdb.size ?? 0)} {formatBytes(cacheData?.imageCache.tmdb.size ?? 0)}
</Table.TD> </Table.TD>
</tr> </tr>
<tr>
<Table.TD>
{intl.formatMessage(messages.useravatars)} (avatar)
</Table.TD>
<Table.TD>
{intl.formatNumber(
cacheData?.imageCache.avatar.imageCount ?? 0
)}
</Table.TD>
<Table.TD>
{formatBytes(cacheData?.imageCache.avatar.size ?? 0)}
</Table.TD>
</tr>
</Table.TBody> </Table.TBody>
</Table> </Table>
</div> </div>

View File

@@ -1,11 +1,11 @@
import Alert from '@app/components/Common/Alert'; import Alert from '@app/components/Common/Alert';
import CachedImage from '@app/components/Common/CachedImage';
import Modal from '@app/components/Common/Modal'; import Modal from '@app/components/Common/Modal';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'; import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import getConfig from 'next/config'; import getConfig from 'next/config';
import Image from 'next/image';
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
@@ -243,9 +243,9 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
</td> </td>
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6"> <td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
<div className="flex items-center"> <div className="flex items-center">
<Image <CachedImage
className="h-10 w-10 flex-shrink-0 rounded-full" className="h-10 w-10 flex-shrink-0 rounded-full"
src={user.thumb} src={`/avatarproxy/${user.thumb}`}
alt="" alt=""
width={40} width={40}
height={40} height={40}

View File

@@ -1,6 +1,7 @@
import Alert from '@app/components/Common/Alert'; import Alert from '@app/components/Common/Alert';
import Badge from '@app/components/Common/Badge'; import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import Header from '@app/components/Common/Header'; import Header from '@app/components/Common/Header';
import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal'; import Modal from '@app/components/Common/Modal';
@@ -29,7 +30,6 @@ import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'
import { hasPermission } from '@server/lib/permissions'; import { hasPermission } from '@server/lib/permissions';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config'; import getConfig from 'next/config';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -634,9 +634,9 @@ const UserList = () => {
href={`/users/${user.id}`} href={`/users/${user.id}`}
className="h-10 w-10 flex-shrink-0" className="h-10 w-10 flex-shrink-0"
> >
<Image <CachedImage
className="h-10 w-10 rounded-full object-cover" className="h-10 w-10 rounded-full object-cover"
src={user.avatar} src={`/avatarproxy/${user.avatar}`}
alt="" alt=""
width={40} width={40}
height={40} height={40}

View File

@@ -1,9 +1,9 @@
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import type { User } from '@app/hooks/useUser'; import type { User } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { CogIcon, UserIcon } from '@heroicons/react/24/solid'; import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -42,9 +42,9 @@ const ProfileHeader = ({ user, isSettingsPage }: ProfileHeaderProps) => {
<div className="flex items-end justify-items-end space-x-5"> <div className="flex items-end justify-items-end space-x-5">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className="relative"> <div className="relative">
<Image <CachedImage
className="h-24 w-24 rounded-full bg-gray-600 object-cover ring-1 ring-gray-700" className="h-24 w-24 rounded-full bg-gray-600 object-cover ring-1 ring-gray-700"
src={user.avatar} src={`/avatarproxy/${user.avatar}`}
alt="" alt=""
width={96} width={96}
height={96} height={96}

View File

@@ -820,6 +820,7 @@
"components.Settings.SettingsJobsCache.runnow": "Run Now", "components.Settings.SettingsJobsCache.runnow": "Run Now",
"components.Settings.SettingsJobsCache.sonarr-scan": "Sonarr Scan", "components.Settings.SettingsJobsCache.sonarr-scan": "Sonarr Scan",
"components.Settings.SettingsJobsCache.unknownJob": "Unknown Job", "components.Settings.SettingsJobsCache.unknownJob": "Unknown Job",
"components.Settings.SettingsJobsCache.useravatars": "User Avatars",
"components.Settings.SettingsLogs.copiedLogMessage": "Copied log message to clipboard.", "components.Settings.SettingsLogs.copiedLogMessage": "Copied log message to clipboard.",
"components.Settings.SettingsLogs.copyToClipboard": "Copy to Clipboard", "components.Settings.SettingsLogs.copyToClipboard": "Copy to Clipboard",
"components.Settings.SettingsLogs.extraData": "Additional Data", "components.Settings.SettingsLogs.extraData": "Additional Data",