refactor: proxy and cache user avatar images

This commit is contained in:
JoaquinOlivero
2024-07-31 10:32:04 +00:00
parent 2b05ffface
commit 8c7a08b4e9
19 changed files with 321 additions and 238 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ class ImageProxy {
let deletedImages = 0;
const cacheDirectory = path.join(baseCacheDirectory, key);
try {
const files = await promises.readdir(cacheDirectory);
for (const file of files) {
@@ -42,14 +43,21 @@ class ImageProxy {
const now = Date.now();
if (now > expireAt) {
await promises.rm(path.join(filePath, imageFile));
await promises.rm(path.join(filePath), {
recursive: true,
});
deletedImages += 1;
}
}
}
}
} catch (e) {
if (e.code === 'ENOENT') {
logger.error(e.message);
}
}
logger.info(`Cleared ${deletedImages} stale image(s) from cache`, {
logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, {
label: 'Image Cache',
});
}
@@ -69,6 +77,7 @@ class ImageProxy {
}
private static async getDirectorySize(dir: string): Promise<number> {
try {
const files = await promises.readdir(dir, {
withFileTypes: true,
});
@@ -90,12 +99,27 @@ class ImageProxy {
return (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
}
}
return 0;
}
private static async getImageCount(dir: string) {
try {
const files = await promises.readdir(dir);
return files.length;
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
}
}
return 0;
}
private fetch: typeof fetch;
@@ -187,16 +211,25 @@ class ImageProxy {
const directory = join(this.getCacheDirectory(), cacheKey);
const href =
this.baseUrl +
(this.baseUrl.endsWith('/') ? '' : '/') +
(this.baseUrl.length > 0
? this.baseUrl.endsWith('/')
? ''
: '/'
: '') +
(path.startsWith('/') ? path.slice(1) : path);
const response = await this.fetch(href);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extension = path.split('.').pop() ?? '';
const maxAge = Number(
const extension = (response.headers.get('content-type') ?? '').replace(
'image/',
''
);
let maxAge = Number(
(response.headers.get('cache-control') ?? '0').split('=')[1]
);
if (!maxAge) maxAge = 604800;
const expireAt = Date.now() + maxAge * 1000;
const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');

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.path.startsWith('/') ? req.path.slice(1) : req.path;
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 avatarImageCache = await ImageProxy.getImageStats('avatar');
return res.status(200).json({
apiCaches,
imageCache: {
tmdb: tmdbImageCache,
avatar: avatarImageCache,
},
});
});

View File

@@ -1,4 +1,5 @@
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import Modal from '@app/components/Common/Modal';
import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
@@ -6,7 +7,6 @@ import { Menu, Transition } from '@headlessui/react';
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import type { default as IssueCommentType } from '@server/entity/IssueComment';
import { Field, Form, Formik } from 'formik';
import Image from 'next/image';
import Link from 'next/link';
import { Fragment, useState } from 'react';
import { FormattedRelativeTime, useIntl } from 'react-intl';
@@ -88,8 +88,8 @@ const IssueComment = ({
</Modal>
</Transition>
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
<Image
src={comment.user.avatar}
<CachedImage
src={`/avatarproxy/${comment.user.avatar}`}
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"
width={40}

View File

@@ -264,8 +264,7 @@ const IssueDetails = () => {
</div>
<h1>
<Link
href={`/${
issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv'
href={`/${issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv'
}/${data.id}`}
className="hover:underline"
>
@@ -287,10 +286,10 @@ const IssueDetails = () => {
}
className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
>
<Image
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={issueData.createdBy.avatar}
<CachedImage
src={`/avatarproxy/${issueData.createdBy.avatar}`}
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}
height={20}
/>

View File

@@ -1,3 +1,4 @@
import CachedImage from '@app/components/Common/CachedImage';
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
@@ -7,7 +8,6 @@ import {
ClockIcon,
} from '@heroicons/react/24/outline';
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
import Image from 'next/image';
import type { LinkProps } from 'next/link';
import Link from 'next/link';
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"
data-testid="user-menu"
>
<Image
<CachedImage
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=""
width={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="flex flex-col space-y-4 px-4 py-4">
<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"
src={user?.avatar || ''}
src={user && user.avatar ? `/avatarproxy/${user.avatar}` : ''}
alt=""
width={40}
height={40}

View File

@@ -1,5 +1,6 @@
import BlacklistBlock from '@app/components/BlacklistBlock';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import ConfirmButton from '@app/components/Common/ConfirmButton';
import SlideOver from '@app/components/Common/SlideOver';
import Tooltip from '@app/components/Common/Tooltip';
@@ -312,8 +313,7 @@ const ManageSlideOver = ({
<div>
{!!watchData?.data && (
<div
className={`grid grid-cols-1 divide-y divide-gray-700 overflow-hidden border-gray-700 text-sm text-gray-300 shadow ${
data.mediaInfo?.tautulliUrl
className={`grid grid-cols-1 divide-y divide-gray-700 overflow-hidden border-gray-700 text-sm text-gray-300 shadow ${data.mediaInfo?.tautulliUrl
? 'rounded-t-md border-x border-t'
: 'rounded-md border'
}`}
@@ -368,8 +368,8 @@ const ManageSlideOver = ({
key={`watch-user-${user.id}`}
content={user.displayName}
>
<Image
src={user.avatar}
<CachedImage
src={`avatarproxy/${user.avatar}`}
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"
width={32}
@@ -391,8 +391,7 @@ const ManageSlideOver = ({
>
<Button
buttonType="ghost"
className={`w-full ${
watchData?.data ? 'rounded-t-none' : ''
className={`w-full ${watchData?.data ? 'rounded-t-none' : ''
}`}
>
<Bars4Icon />
@@ -471,8 +470,7 @@ const ManageSlideOver = ({
<div>
{watchData?.data4k && (
<div
className={`grid grid-cols-1 divide-y divide-gray-700 overflow-hidden border-gray-700 text-sm text-gray-300 shadow ${
data.mediaInfo?.tautulliUrl4k
className={`grid grid-cols-1 divide-y divide-gray-700 overflow-hidden border-gray-700 text-sm text-gray-300 shadow ${data.mediaInfo?.tautulliUrl4k
? 'rounded-t-md border-x border-t'
: 'rounded-md border'
}`}
@@ -529,8 +527,8 @@ const ManageSlideOver = ({
key={`watch-user-${user.id}`}
content={user.displayName}
>
<Image
src={user.avatar}
<CachedImage
src={`avatarproxy/${user.avatar}`}
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"
width={32}
@@ -552,8 +550,7 @@ const ManageSlideOver = ({
>
<Button
buttonType="ghost"
className={`w-full ${
watchData?.data4k ? 'rounded-t-none' : ''
className={`w-full ${watchData?.data4k ? 'rounded-t-none' : ''
}`}
>
<Bars4Icon />

View File

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

View File

@@ -1,4 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */
import CachedImage from '@app/components/Common/CachedImage';
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import type { User } 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 { hasPermission } from '@server/lib/permissions';
import { isEqual } from 'lodash';
import Image from 'next/image';
import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import Select from 'react-select';
@@ -561,8 +561,8 @@ const AdvancedRequester = ({
<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">
<span className="flex items-center">
<Image
src={selectedUser.avatar}
<CachedImage
src={`/avatarproxy/${selectedUser.avatar}`}
alt=""
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
width={24}
@@ -613,8 +613,8 @@ const AdvancedRequester = ({
selected ? 'font-semibold' : 'font-normal'
} flex items-center`}
>
<Image
src={user.avatar}
<CachedImage
src={`/avatarproxy/${selectedUser.avatar}`}
alt=""
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
width={24}

View File

@@ -82,6 +82,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>.',
imagecachecount: 'Images Cached',
imagecachesize: 'Total Cache Size',
useravatars: 'User Avatars',
}
);
@@ -573,6 +574,19 @@ const SettingsJobs = () => {
{formatBytes(cacheData?.imageCache.tmdb.size ?? 0)}
</Table.TD>
</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>
</div>

View File

@@ -1,4 +1,5 @@
import Alert from '@app/components/Common/Alert';
import CachedImage from '@app/components/Common/CachedImage';
import Modal from '@app/components/Common/Modal';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
@@ -189,14 +190,12 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
>
<span
aria-hidden="true"
className={`${
isAllUsers() ? 'bg-indigo-500' : 'bg-gray-800'
className={`${isAllUsers() ? 'bg-indigo-500' : 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
isAllUsers() ? 'translate-x-5' : 'translate-x-0'
className={`${isAllUsers() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
@@ -231,16 +230,14 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
>
<span
aria-hidden="true"
className={`${
isSelectedUser(user.id)
className={`${isSelectedUser(user.id)
? 'bg-indigo-500'
: 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
isSelectedUser(user.id)
className={`${isSelectedUser(user.id)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
@@ -249,9 +246,9 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
</td>
<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">
<Image
<CachedImage
className="h-10 w-10 flex-shrink-0 rounded-full"
src={user.thumb}
src={`/avatarproxy/${user.thumb}`}
alt=""
width={40}
height={40}

View File

@@ -1,6 +1,7 @@
import Alert from '@app/components/Common/Alert';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import Header from '@app/components/Common/Header';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
@@ -103,8 +104,7 @@ const UserList = () => {
error,
mutate: revalidate,
} = useSWR<UserResultsResponse>(
`/api/v1/user?take=${currentPageSize}&skip=${
pageIndex * currentPageSize
`/api/v1/user?take=${currentPageSize}&skip=${pageIndex * currentPageSize
}&sort=${currentSort}`
);
@@ -406,8 +406,7 @@ const UserList = () => {
</div>
</div>
<div
className={`form-row ${
passwordGenerationEnabled ? '' : 'opacity-50'
className={`form-row ${passwordGenerationEnabled ? '' : 'opacity-50'
}`}
>
<label htmlFor="genpassword" className="checkbox-label">
@@ -427,8 +426,7 @@ const UserList = () => {
</div>
</div>
<div
className={`form-row ${
values.genpassword ? 'opacity-50' : ''
className={`form-row ${values.genpassword ? 'opacity-50' : ''
}`}
>
<label htmlFor="password" className="text-label">
@@ -633,9 +631,9 @@ const UserList = () => {
href={`/users/${user.id}`}
className="h-10 w-10 flex-shrink-0"
>
<Image
<CachedImage
className="h-10 w-10 rounded-full object-cover"
src={user.avatar}
src={`/avatarproxy/${user.avatar}`}
alt=""
width={40}
height={40}

View File

@@ -1,9 +1,10 @@
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import type { User } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
import Image from 'next/image';
// import Image from 'next/image';
import Link from 'next/link';
import { useIntl } from 'react-intl';
@@ -42,9 +43,9 @@ const ProfileHeader = ({ user, isSettingsPage }: ProfileHeaderProps) => {
<div className="flex items-end justify-items-end space-x-5">
<div className="flex-shrink-0">
<div className="relative">
<Image
<CachedImage
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=""
width={96}
height={96}