mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-31 19:59:31 -05:00
Compare commits
9 Commits
0xsysr3ll/
...
preview-ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
374eaee616 | ||
|
|
7e7a65caee | ||
|
|
7369802146 | ||
|
|
4b18817893 | ||
|
|
5fc5c2b4a3 | ||
|
|
f8ae87f2b5 | ||
|
|
894799d626 | ||
|
|
98b388b999 | ||
|
|
6ab41a5ec2 |
@@ -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) {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
25
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,29 +28,45 @@ class ImageProxy {
|
|||||||
let deletedImages = 0;
|
let deletedImages = 0;
|
||||||
const cacheDirectory = path.join(baseCacheDirectory, key);
|
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||||
|
|
||||||
const files = await promises.readdir(cacheDirectory);
|
try {
|
||||||
|
const files = await promises.readdir(cacheDirectory);
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const filePath = path.join(cacheDirectory, file);
|
const filePath = path.join(cacheDirectory, file);
|
||||||
const stat = await promises.lstat(filePath);
|
const stat = await promises.lstat(filePath);
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
const imageFiles = await promises.readdir(filePath);
|
const imageFiles = await promises.readdir(filePath);
|
||||||
|
|
||||||
for (const imageFile of imageFiles) {
|
for (const imageFile of imageFiles) {
|
||||||
const [, expireAtSt] = imageFile.split('.');
|
const [, expireAtSt] = imageFile.split('.');
|
||||||
const expireAt = Number(expireAtSt);
|
const expireAt = Number(expireAtSt);
|
||||||
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), {
|
||||||
deletedImages += 1;
|
recursive: true,
|
||||||
|
});
|
||||||
|
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,33 +86,49 @@ class ImageProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async getDirectorySize(dir: string): Promise<number> {
|
private static async getDirectorySize(dir: string): Promise<number> {
|
||||||
const files = await promises.readdir(dir, {
|
try {
|
||||||
withFileTypes: true,
|
const files = await promises.readdir(dir, {
|
||||||
});
|
withFileTypes: true,
|
||||||
|
});
|
||||||
|
|
||||||
const paths = files.map(async (file) => {
|
const paths = files.map(async (file) => {
|
||||||
const path = join(dir, file.name);
|
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()) {
|
if (file.isFile()) {
|
||||||
const { size } = await promises.stat(path);
|
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 0;
|
||||||
});
|
|
||||||
|
|
||||||
return (await Promise.all(paths))
|
|
||||||
.flat(Infinity)
|
|
||||||
.reduce((i, size) => i + size, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async getImageCount(dir: string) {
|
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;
|
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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
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.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;
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user