refactor: proxy and cache user avatar images
This commit is contained in:
@@ -10,7 +10,6 @@ module.exports = {
|
||||
remotePatterns: [
|
||||
{ hostname: 'gravatar.com' },
|
||||
{ hostname: 'image.tmdb.org' },
|
||||
{ hostname: '*', protocol: 'https' },
|
||||
],
|
||||
},
|
||||
webpack(config) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -227,6 +227,9 @@ export const startJobs = (): void => {
|
||||
});
|
||||
// Clean TMDB image cache
|
||||
ImageProxy.clearCache('tmdb');
|
||||
|
||||
// Clean users avatar image cache
|
||||
ImageProxy.clearCache('avatar');
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -27,29 +27,37 @@ class ImageProxy {
|
||||
let deletedImages = 0;
|
||||
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||
|
||||
const files = await promises.readdir(cacheDirectory);
|
||||
try {
|
||||
const files = await promises.readdir(cacheDirectory);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(cacheDirectory, file);
|
||||
const stat = await promises.lstat(filePath);
|
||||
for (const file of files) {
|
||||
const filePath = path.join(cacheDirectory, file);
|
||||
const stat = await promises.lstat(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const imageFiles = await promises.readdir(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
const imageFiles = await promises.readdir(filePath);
|
||||
|
||||
for (const imageFile of imageFiles) {
|
||||
const [, expireAtSt] = imageFile.split('.');
|
||||
const expireAt = Number(expireAtSt);
|
||||
const now = Date.now();
|
||||
for (const imageFile of imageFiles) {
|
||||
const [, expireAtSt] = imageFile.split('.');
|
||||
const expireAt = Number(expireAtSt);
|
||||
const now = Date.now();
|
||||
|
||||
if (now > expireAt) {
|
||||
await promises.rm(path.join(filePath, imageFile));
|
||||
deletedImages += 1;
|
||||
if (now > expireAt) {
|
||||
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,33 +77,49 @@ class ImageProxy {
|
||||
}
|
||||
|
||||
private static async getDirectorySize(dir: string): Promise<number> {
|
||||
const files = await promises.readdir(dir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
try {
|
||||
const files = await promises.readdir(dir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
const paths = files.map(async (file) => {
|
||||
const path = join(dir, file.name);
|
||||
const paths = files.map(async (file) => {
|
||||
const path = join(dir, file.name);
|
||||
|
||||
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
|
||||
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
|
||||
|
||||
if (file.isFile()) {
|
||||
const { size } = await promises.stat(path);
|
||||
if (file.isFile()) {
|
||||
const { size } = await promises.stat(path);
|
||||
|
||||
return size;
|
||||
return size;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (await Promise.all(paths))
|
||||
.flat(Infinity)
|
||||
.reduce((i, size) => i + size, 0);
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (await Promise.all(paths))
|
||||
.flat(Infinity)
|
||||
.reduce((i, size) => i + size, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async getImageCount(dir: string) {
|
||||
const files = await promises.readdir(dir);
|
||||
try {
|
||||
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;
|
||||
@@ -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, '');
|
||||
|
||||
|
||||
31
server/routes/avatarproxy.ts
Normal file
31
server/routes/avatarproxy.ts
Normal 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;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -264,9 +264,8 @@ const IssueDetails = () => {
|
||||
</div>
|
||||
<h1>
|
||||
<Link
|
||||
href={`/${
|
||||
issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv'
|
||||
}/${data.id}`}
|
||||
href={`/${issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv'
|
||||
}/${data.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{title}
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -303,7 +302,7 @@ const IssueDetails = () => {
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(issueData.createdAt).getTime() - Date.now()) /
|
||||
1000
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
numeric="auto"
|
||||
@@ -368,7 +367,7 @@ const IssueDetails = () => {
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(issueData.updatedAt).getTime() - Date.now()) /
|
||||
1000
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
numeric="auto"
|
||||
@@ -389,16 +388,16 @@ const IssueDetails = () => {
|
||||
<PlayIcon />
|
||||
<span>
|
||||
{settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
MediaServerType.EMBY
|
||||
? intl.formatMessage(messages.playonplex, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
: settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.PLEX
|
||||
? intl.formatMessage(messages.playonplex, {
|
||||
? intl.formatMessage(messages.playonplex, {
|
||||
mediaServerName: 'Plex',
|
||||
})
|
||||
: intl.formatMessage(messages.playonplex, {
|
||||
: intl.formatMessage(messages.playonplex, {
|
||||
mediaServerName: 'Jellyfin',
|
||||
})}
|
||||
</span>
|
||||
@@ -437,16 +436,16 @@ const IssueDetails = () => {
|
||||
<PlayIcon />
|
||||
<span>
|
||||
{settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
MediaServerType.EMBY
|
||||
? intl.formatMessage(messages.play4konplex, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
: settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.PLEX
|
||||
? intl.formatMessage(messages.play4konplex, {
|
||||
? intl.formatMessage(messages.play4konplex, {
|
||||
mediaServerName: 'Plex',
|
||||
})
|
||||
: intl.formatMessage(messages.play4konplex, {
|
||||
: intl.formatMessage(messages.play4konplex, {
|
||||
mediaServerName: 'Jellyfin',
|
||||
})}
|
||||
</span>
|
||||
@@ -531,52 +530,52 @@ const IssueDetails = () => {
|
||||
<div className="mt-4 flex items-center justify-end space-x-2">
|
||||
{(hasPermission(Permission.MANAGE_ISSUES) ||
|
||||
belongsToUser) && (
|
||||
<>
|
||||
{issueData.status === IssueStatus.OPEN ? (
|
||||
<Button
|
||||
type="button"
|
||||
buttonType="danger"
|
||||
onClick={async () => {
|
||||
await updateIssueStatus('resolved');
|
||||
<>
|
||||
{issueData.status === IssueStatus.OPEN ? (
|
||||
<Button
|
||||
type="button"
|
||||
buttonType="danger"
|
||||
onClick={async () => {
|
||||
await updateIssueStatus('resolved');
|
||||
|
||||
if (values.message) {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
values.message
|
||||
? messages.closeissueandcomment
|
||||
: messages.closeissue
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
buttonType="default"
|
||||
onClick={async () => {
|
||||
await updateIssueStatus('open');
|
||||
if (values.message) {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
values.message
|
||||
? messages.closeissueandcomment
|
||||
: messages.closeissue
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
buttonType="default"
|
||||
onClick={async () => {
|
||||
await updateIssueStatus('open');
|
||||
|
||||
if (values.message) {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowPathIcon />
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
values.message
|
||||
? messages.reopenissueandcomment
|
||||
: messages.reopenissue
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
if (values.message) {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowPathIcon />
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
values.message
|
||||
? messages.reopenissueandcomment
|
||||
: messages.reopenissue
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
buttonType="primary"
|
||||
@@ -642,7 +641,7 @@ const IssueDetails = () => {
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(issueData.updatedAt).getTime() - Date.now()) /
|
||||
1000
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
numeric="auto"
|
||||
@@ -663,16 +662,16 @@ const IssueDetails = () => {
|
||||
<PlayIcon />
|
||||
<span>
|
||||
{settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
MediaServerType.EMBY
|
||||
? intl.formatMessage(messages.playonplex, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
: settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.PLEX
|
||||
? intl.formatMessage(messages.playonplex, {
|
||||
? intl.formatMessage(messages.playonplex, {
|
||||
mediaServerName: 'Plex',
|
||||
})
|
||||
: intl.formatMessage(messages.playonplex, {
|
||||
: intl.formatMessage(messages.playonplex, {
|
||||
mediaServerName: 'Jellyfin',
|
||||
})}
|
||||
</span>
|
||||
@@ -710,16 +709,16 @@ const IssueDetails = () => {
|
||||
<PlayIcon />
|
||||
<span>
|
||||
{settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
MediaServerType.EMBY
|
||||
? intl.formatMessage(messages.play4konplex, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
: settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.PLEX
|
||||
? intl.formatMessage(messages.play4konplex, {
|
||||
? intl.formatMessage(messages.play4konplex, {
|
||||
mediaServerName: 'Plex',
|
||||
})
|
||||
: intl.formatMessage(messages.play4konplex, {
|
||||
: intl.formatMessage(messages.play4konplex, {
|
||||
mediaServerName: 'Jellyfin',
|
||||
})}
|
||||
</span>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
@@ -211,36 +212,36 @@ const ManageSlideOver = ({
|
||||
<div className="space-y-6">
|
||||
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
|
||||
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.downloadstatus)}
|
||||
</h3>
|
||||
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
||||
<Tooltip
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
content={status.title}
|
||||
>
|
||||
<li className="border-b border-gray-700 last:border-b-0">
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
</Tooltip>
|
||||
))}
|
||||
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
||||
<Tooltip
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
content={status.title}
|
||||
>
|
||||
<li className="border-b border-gray-700 last:border-b-0">
|
||||
<DownloadBlock downloadItem={status} is4k />
|
||||
</li>
|
||||
</Tooltip>
|
||||
))}
|
||||
</ul>
|
||||
<div>
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.downloadstatus)}
|
||||
</h3>
|
||||
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
||||
<Tooltip
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
content={status.title}
|
||||
>
|
||||
<li className="border-b border-gray-700 last:border-b-0">
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
</Tooltip>
|
||||
))}
|
||||
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
||||
<Tooltip
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
content={status.title}
|
||||
>
|
||||
<li className="border-b border-gray-700 last:border-b-0">
|
||||
<DownloadBlock downloadItem={status} is4k />
|
||||
</li>
|
||||
</Tooltip>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], {
|
||||
type: 'or',
|
||||
}) &&
|
||||
@@ -312,11 +313,10 @@ 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
|
||||
? 'rounded-t-md border-x border-t'
|
||||
: 'rounded-md border'
|
||||
}`}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<div className="grid grid-cols-3 divide-x divide-gray-700">
|
||||
<div className="px-4 py-3">
|
||||
@@ -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,9 +391,8 @@ const ManageSlideOver = ({
|
||||
>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
className={`w-full ${
|
||||
watchData?.data ? 'rounded-t-none' : ''
|
||||
}`}
|
||||
className={`w-full ${watchData?.data ? 'rounded-t-none' : ''
|
||||
}`}
|
||||
>
|
||||
<Bars4Icon />
|
||||
<span>
|
||||
@@ -471,11 +470,10 @@ 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
|
||||
? 'rounded-t-md border-x border-t'
|
||||
: 'rounded-md border'
|
||||
}`}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<div className="grid grid-cols-3 divide-x divide-gray-700">
|
||||
<div className="px-4 py-3">
|
||||
@@ -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,9 +550,8 @@ const ManageSlideOver = ({
|
||||
>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
className={`w-full ${
|
||||
watchData?.data4k ? 'rounded-t-none' : ''
|
||||
}`}
|
||||
className={`w-full ${watchData?.data4k ? 'rounded-t-none' : ''
|
||||
}`}
|
||||
>
|
||||
<Bars4Icon />
|
||||
<span>
|
||||
@@ -677,12 +674,12 @@ const ManageSlideOver = ({
|
||||
),
|
||||
mediaServerName:
|
||||
settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.PLEX
|
||||
? 'Plex'
|
||||
: 'Jellyfin',
|
||||
? 'Plex'
|
||||
: 'Jellyfin',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -157,7 +158,7 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
||||
title={intl.formatMessage(messages.newJellyfinsigninenabled, {
|
||||
mediaServerName:
|
||||
settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: 'Jellyfin',
|
||||
strong: (msg: React.ReactNode) => (
|
||||
@@ -189,15 +190,13 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isAllUsers() ? 'bg-indigo-500' : 'bg-gray-800'
|
||||
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
||||
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'
|
||||
} 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`}
|
||||
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>
|
||||
</th>
|
||||
@@ -231,27 +230,25 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
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`}
|
||||
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)
|
||||
? '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`}
|
||||
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`}
|
||||
></span>
|
||||
</span>
|
||||
</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}
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
|
||||
@@ -215,9 +215,9 @@ const UserList = () => {
|
||||
!value
|
||||
? Yup.string()
|
||||
: Yup.string().min(
|
||||
8,
|
||||
intl.formatMessage(messages.validationpasswordminchars)
|
||||
)
|
||||
8,
|
||||
intl.formatMessage(messages.validationpasswordminchars)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
@@ -406,9 +406,8 @@ const UserList = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`form-row ${
|
||||
passwordGenerationEnabled ? '' : 'opacity-50'
|
||||
}`}
|
||||
className={`form-row ${passwordGenerationEnabled ? '' : 'opacity-50'
|
||||
}`}
|
||||
>
|
||||
<label htmlFor="genpassword" className="checkbox-label">
|
||||
{intl.formatMessage(messages.autogeneratepassword)}
|
||||
@@ -427,9 +426,8 @@ 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">
|
||||
{intl.formatMessage(messages.password)}
|
||||
@@ -534,16 +532,16 @@ const UserList = () => {
|
||||
<InboxArrowDownIcon />
|
||||
<span>
|
||||
{settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
MediaServerType.EMBY
|
||||
? intl.formatMessage(messages.importfrommediaserver, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
: settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.PLEX
|
||||
? intl.formatMessage(messages.importfrommediaserver, {
|
||||
? intl.formatMessage(messages.importfrommediaserver, {
|
||||
mediaServerName: 'Plex',
|
||||
})
|
||||
: intl.formatMessage(messages.importfrommediaserver, {
|
||||
: intl.formatMessage(messages.importfrommediaserver, {
|
||||
mediaServerName: 'Jellyfin',
|
||||
})}
|
||||
</span>
|
||||
@@ -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}
|
||||
@@ -657,19 +655,19 @@ const UserList = () => {
|
||||
user.jellyfinUsername ||
|
||||
user.plexUsername
|
||||
)?.toLowerCase() !== user.email && (
|
||||
<div className="text-sm leading-5 text-gray-300">
|
||||
{user.email}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm leading-5 text-gray-300">
|
||||
{user.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
{user.id === currentUser?.id ||
|
||||
currentHasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||
{ type: 'or' }
|
||||
) ? (
|
||||
currentHasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||
{ type: 'or' }
|
||||
) ? (
|
||||
<Link
|
||||
href={`/users/${user.id}/requests`}
|
||||
className="text-sm leading-5 transition duration-300 hover:underline"
|
||||
@@ -707,8 +705,8 @@ const UserList = () => {
|
||||
{user.id === 1
|
||||
? intl.formatMessage(messages.owner)
|
||||
: hasPermission(Permission.ADMIN, user.permissions)
|
||||
? intl.formatMessage(messages.admin)
|
||||
: intl.formatMessage(messages.user)}
|
||||
? intl.formatMessage(messages.admin)
|
||||
: intl.formatMessage(messages.user)}
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
{intl.formatDate(user.createdAt, {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user