mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-27 03:59:54 -05:00
Compare commits
16 Commits
preview-no
...
preview-ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c195a6f720 | ||
|
|
f7a4efdde1 | ||
|
|
6a475b5e41 | ||
|
|
ae5542b6d3 | ||
|
|
cbf3bbb17d | ||
|
|
14fdd4e293 | ||
|
|
d2f651081a | ||
|
|
822ca690da | ||
|
|
b2291f5125 | ||
|
|
84f7a9795e | ||
|
|
9c5f4fe2f0 | ||
|
|
3af39dd5f0 | ||
|
|
7ad44830d1 | ||
|
|
bd8deedfbe | ||
|
|
548aaede26 | ||
|
|
8c7a08b4e9 |
@@ -10,7 +10,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) {
|
||||||
|
|||||||
@@ -2790,6 +2790,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]} 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';
|
||||||
@@ -342,6 +343,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
}),
|
}),
|
||||||
userType: UserType.EMBY,
|
userType: UserType.EMBY,
|
||||||
});
|
});
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case MediaServerType.JELLYFIN:
|
case MediaServerType.JELLYFIN:
|
||||||
settings.main.mediaServerType = MediaServerType.JELLYFIN;
|
settings.main.mediaServerType = MediaServerType.JELLYFIN;
|
||||||
@@ -360,6 +362,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
}),
|
}),
|
||||||
userType: UserType.JELLYFIN,
|
userType: UserType.JELLYFIN,
|
||||||
});
|
});
|
||||||
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error('select_server_type');
|
throw new Error('select_server_type');
|
||||||
@@ -407,12 +410,24 @@ 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;
|
||||||
|
|
||||||
@@ -462,6 +477,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
? UserType.JELLYFIN
|
? UserType.JELLYFIN
|
||||||
: UserType.EMBY,
|
: UserType.EMBY,
|
||||||
});
|
});
|
||||||
|
|
||||||
//initialize Jellyfin/Emby users with local login
|
//initialize Jellyfin/Emby users with local login
|
||||||
const passedExplicitPassword = body.password && body.password.length > 0;
|
const passedExplicitPassword = body.password && body.password.length > 0;
|
||||||
if (passedExplicitPassword) {
|
if (passedExplicitPassword) {
|
||||||
|
|||||||
32
server/routes/avatarproxy.ts
Normal file
32
server/routes/avatarproxy.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ const CachedImage = ({ src, ...props }: ImageProps) => {
|
|||||||
if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
|
if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
|
||||||
const parsedUrl = new URL(imageUrl);
|
const parsedUrl = new URL(imageUrl);
|
||||||
|
|
||||||
if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) {
|
if (parsedUrl.host === 'image.tmdb.org') {
|
||||||
imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy');
|
if (currentSettings.cacheImages)
|
||||||
|
imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy');
|
||||||
|
} else if (parsedUrl.host !== 'gravatar.com') {
|
||||||
|
imageUrl = '/avatarproxy/' + imageUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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={`${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}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import type Issue from '@server/entity/Issue';
|
|||||||
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 { 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 { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -287,10 +286,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={`${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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { MediaType } from '@server/constants/media';
|
|||||||
import type Issue from '@server/entity/Issue';
|
import type Issue from '@server/entity/Issue';
|
||||||
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 { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
||||||
@@ -226,8 +225,8 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
href={`/users/${issue.createdBy.id}`}
|
href={`/users/${issue.createdBy.id}`}
|
||||||
className="group flex items-center truncate"
|
className="group flex items-center truncate"
|
||||||
>
|
>
|
||||||
<Image
|
<CachedImage
|
||||||
src={issue.createdBy.avatar}
|
src={'/avatarproxy/' + issue.createdBy.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="avatar-sm ml-1.5 object-cover"
|
className="avatar-sm ml-1.5 object-cover"
|
||||||
width={20}
|
width={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 : ''}
|
||||||
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 : ''}
|
||||||
alt=""
|
alt=""
|
||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={40}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import BlacklistBlock from '@app/components/BlacklistBlock';
|
import BlacklistBlock from '@app/components/BlacklistBlock';
|
||||||
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 { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfa
|
|||||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
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 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';
|
||||||
@@ -368,7 +368,7 @@ const ManageSlideOver = ({
|
|||||||
key={`watch-user-${user.id}`}
|
key={`watch-user-${user.id}`}
|
||||||
content={user.displayName}
|
content={user.displayName}
|
||||||
>
|
>
|
||||||
<Image
|
<CachedImage
|
||||||
src={user.avatar}
|
src={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"
|
||||||
@@ -529,7 +529,7 @@ const ManageSlideOver = ({
|
|||||||
key={`watch-user-${user.id}`}
|
key={`watch-user-${user.id}`}
|
||||||
content={user.displayName}
|
content={user.displayName}
|
||||||
>
|
>
|
||||||
<Image
|
<CachedImage
|
||||||
src={user.avatar}
|
src={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"
|
||||||
|
|||||||
@@ -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,7 +115,7 @@ 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={requestData.requestedBy.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="avatar-sm object-cover"
|
className="avatar-sm object-cover"
|
||||||
@@ -390,7 +389,7 @@ 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={requestData.requestedBy.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="avatar-sm object-cover"
|
className="avatar-sm object-cover"
|
||||||
|
|||||||
@@ -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,7 +189,7 @@ 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={requestData.requestedBy.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="avatar-sm object-cover"
|
className="avatar-sm object-cover"
|
||||||
@@ -249,7 +248,7 @@ 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={requestData.modifiedBy.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="avatar-sm object-cover"
|
className="avatar-sm object-cover"
|
||||||
@@ -557,7 +556,7 @@ 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={requestData.requestedBy.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="avatar-sm object-cover"
|
className="avatar-sm object-cover"
|
||||||
@@ -616,7 +615,7 @@ 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={requestData.requestedBy.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="avatar-sm object-cover"
|
className="avatar-sm object-cover"
|
||||||
|
|||||||
@@ -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,7 +561,7 @@ 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={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"
|
||||||
@@ -613,7 +613,7 @@ const AdvancedRequester = ({
|
|||||||
selected ? 'font-semibold' : 'font-normal'
|
selected ? 'font-semibold' : 'font-normal'
|
||||||
} flex items-center`}
|
} flex items-center`}
|
||||||
>
|
>
|
||||||
<Image
|
<CachedImage
|
||||||
src={user.avatar}
|
src={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"
|
||||||
|
|||||||
@@ -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>.',
|
'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',
|
||||||
|
usersavatars: "Users' Avatars",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -573,6 +574,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.usersavatars)} (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 { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
|
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
|
||||||
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';
|
||||||
@@ -249,7 +249,7 @@ 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={user.thumb}
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -28,7 +29,6 @@ import { MediaServerType } from '@server/constants/server';
|
|||||||
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 { 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 { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
@@ -633,7 +633,7 @@ 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={user.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -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,7 +42,7 @@ 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={user.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -849,6 +849,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.usersavatars": "Users' 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",
|
||||||
@@ -968,6 +969,7 @@
|
|||||||
"components.Settings.address": "Address",
|
"components.Settings.address": "Address",
|
||||||
"components.Settings.addsonarr": "Add Sonarr Server",
|
"components.Settings.addsonarr": "Add Sonarr Server",
|
||||||
"components.Settings.advancedTooltip": "Incorrectly configuring this setting may result in broken functionality",
|
"components.Settings.advancedTooltip": "Incorrectly configuring this setting may result in broken functionality",
|
||||||
|
"components.Settings.apiKey": "API key",
|
||||||
"components.Settings.cancelscan": "Cancel Scan",
|
"components.Settings.cancelscan": "Cancel Scan",
|
||||||
"components.Settings.copied": "Copied API key to clipboard.",
|
"components.Settings.copied": "Copied API key to clipboard.",
|
||||||
"components.Settings.currentlibrary": "Current Library: {name}",
|
"components.Settings.currentlibrary": "Current Library: {name}",
|
||||||
|
|||||||
Reference in New Issue
Block a user