Merge remote-tracking branch 'overseerr/develop' into develop

This commit is contained in:
notfakie
2022-12-16 19:58:30 +13:00
32 changed files with 711 additions and 152 deletions

View File

@@ -737,6 +737,24 @@
"contributions": [ "contributions": [
"translation" "translation"
] ]
},
{
"login": "Eclipseop",
"name": "Mackenzie",
"avatar_url": "https://avatars.githubusercontent.com/u/5846213?v=4",
"profile": "https://github.com/Eclipseop",
"contributions": [
"code"
]
},
{
"login": "s0up4200",
"name": "soup",
"avatar_url": "https://avatars.githubusercontent.com/u/18177310?v=4",
"profile": "https://github.com/s0up4200",
"contributions": [
"doc"
]
} }
], ],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>", "badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
@@ -745,5 +763,6 @@
"projectOwner": "sct", "projectOwner": "sct",
"repoType": "github", "repoType": "github",
"repoHost": "https://github.com", "repoHost": "https://github.com",
"skipCi": false "skipCi": false,
"commitConvention": "angular"
} }

3
.gitignore vendored
View File

@@ -67,3 +67,6 @@ tsconfig.tsbuildinfo
# Webstorm # Webstorm
.idea .idea
# Config Cache Directory
config/cache

View File

@@ -11,4 +11,4 @@ To use Fail2ban with Overseerr, create a new file named `overseerr.local` in you
failregex = .*\[warn\]\[API\]\: Failed sign-in attempt.*"ip":"<HOST>" failregex = .*\[warn\]\[API\]\: Failed sign-in attempt.*"ip":"<HOST>"
``` ```
You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documetation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail. You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documentation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail.

View File

@@ -40,6 +40,14 @@ If you enable this setting and find yourself unable to access Overseerr, you can
This setting is **disabled** by default. This setting is **disabled** by default.
### Enable Image Caching
When enabled, Overseerr will proxy and cache images from pre-configured sources (such as TMDB). This can use a significant amount of disk space.
Images are saved in the `config/cache/images` and stale images are cleared out every 24 hours.
You should enable this if you are having issues with loading images directly from TMDB in your browser.
### Display Language ### Display Language
Set the default display language for Overseerr. Users can override this setting in their user settings. Set the default display language for Overseerr. Users can override this setting in their user settings.

View File

@@ -2667,29 +2667,44 @@ paths:
content: content:
application/json: application/json:
schema: schema:
type: array type: object
items: properties:
type: object imageCache:
properties: type: object
id: properties:
type: string tmdb:
example: cache-id type: object
name: properties:
type: string size:
example: cache name type: number
stats: example: 123456
imageCount:
type: number
example: 123
apiCaches:
type: array
items:
type: object type: object
properties: properties:
hits: id:
type: number type: string
misses: example: cache-id
type: number name:
keys: type: string
type: number example: cache name
ksize: stats:
type: number type: object
vsize: properties:
type: number hits:
type: number
misses:
type: number
keys:
type: number
ksize:
type: number
vsize:
type: number
/settings/cache/{cacheId}/flush: /settings/cache/{cacheId}/flush:
post: post:
summary: Flush a specific cache summary: Flush a specific cache
@@ -4838,9 +4853,13 @@ paths:
type: number type: number
example: 123 example: 123
seasons: seasons:
type: array oneOf:
items: - type: array
type: number items:
type: number
minimum: 1
- type: string
enum: [all]
is4k: is4k:
type: boolean type: boolean
example: false example: false
@@ -4919,7 +4938,7 @@ paths:
$ref: '#/components/schemas/MediaRequest' $ref: '#/components/schemas/MediaRequest'
put: put:
summary: Update MediaRequest summary: Update MediaRequest
description: Updates a specific media request and returns the request in a JSON object.. Requires the `MANAGE_REQUESTS` permission. description: Updates a specific media request and returns the request in a JSON object. Requires the `MANAGE_REQUESTS` permission.
tags: tags:
- request - request
parameters: parameters:
@@ -4930,6 +4949,37 @@ paths:
example: '1' example: '1'
schema: schema:
type: string type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
mediaType:
type: string
enum: [movie, tv]
seasons:
type: array
items:
type: number
minimum: 1
is4k:
type: boolean
example: false
serverId:
type: number
profileId:
type: number
rootFolder:
type: string
languageProfileId:
type: number
userId:
type: number
nullable: true
required:
- mediaType
responses: responses:
'200': '200':
description: Succesfully updated request description: Succesfully updated request

View File

@@ -225,7 +225,7 @@
{ {
"path": "semantic-release-docker-buildx", "path": "semantic-release-docker-buildx",
"buildArgs": { "buildArgs": {
"COMMIT_TAG": "$GITHUB_SHA" "COMMIT_TAG": "$GIT_SHA"
}, },
"imageNames": [ "imageNames": [
"fallenbagel/jellyseerr" "fallenbagel/jellyseerr"

View File

@@ -205,8 +205,8 @@ class Media {
? externalHostname ? externalHostname
: hostname; : hostname;
jellyfinHost = jellyfinHost!.endsWith('/') jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost!.slice(0, -1) ? jellyfinHost.slice(0, -1)
: jellyfinHost; : jellyfinHost;
if (this.jellyfinMediaId) { if (this.jellyfinMediaId) {

View File

@@ -39,7 +39,7 @@ export class User {
return users.map((u) => u.filter(showFiltered)); return users.map((u) => u.filter(showFiltered));
} }
static readonly filteredFields: string[] = ['email']; static readonly filteredFields: string[] = ['email', 'plexId'];
public displayName: string; public displayName: string;
@@ -76,7 +76,7 @@ export class User {
@Column({ type: 'integer', default: UserType.PLEX }) @Column({ type: 'integer', default: UserType.PLEX })
public userType: UserType; public userType: UserType;
@Column({ nullable: true }) @Column({ nullable: true, select: true })
public plexId?: number; public plexId?: number;
@Column({ nullable: true }) @Column({ nullable: true })

View File

@@ -17,6 +17,7 @@ import WebPushAgent from '@server/lib/notifications/agents/webpush';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import routes from '@server/routes'; import routes from '@server/routes';
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';
import { getClientIp } from '@supercharge/request-ip'; import { getClientIp } from '@supercharge/request-ip';
@@ -186,6 +187,9 @@ app
next(); next();
}); });
server.use('/api/v1', routes); server.use('/api/v1', routes);
server.use('/imageproxy', imageproxy);
server.get('*', (req, res) => handle(req, res)); server.get('*', (req, res) => handle(req, res));
server.use( server.use(
( (

View File

@@ -54,6 +54,11 @@ export interface CacheItem {
}; };
} }
export interface CacheResponse {
apiCaches: CacheItem[];
imageCache: Record<'tmdb', { size: number; imageCount: number }>;
}
export interface StatusResponse { export interface StatusResponse {
version: string; version: string;
commitTag: string; commitTag: string;

View File

@@ -1,5 +1,6 @@
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import downloadTracker from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex'; import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
import { radarrScanner } from '@server/lib/scanners/radarr'; import { radarrScanner } from '@server/lib/scanners/radarr';
import { sonarrScanner } from '@server/lib/scanners/sonarr'; import { sonarrScanner } from '@server/lib/scanners/sonarr';
@@ -181,5 +182,21 @@ export const startJobs = (): void => {
}), }),
}); });
// Run image cache cleanup every 5 minutes
scheduledJobs.push({
id: 'image-cache-cleanup',
name: 'Image Cache Cleanup',
type: 'process',
interval: 'long',
cronSchedule: jobs['image-cache-cleanup'].schedule,
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
logger.info('Starting scheduled job: Image Cache Cleanup', {
label: 'Jobs',
});
// Clean TMDB image cache
ImageProxy.clearCache('tmdb');
}),
});
logger.info('Scheduled jobs loaded', { label: 'Jobs' }); logger.info('Scheduled jobs loaded', { label: 'Jobs' });
}; };

268
server/lib/imageproxy.ts Normal file
View File

@@ -0,0 +1,268 @@
import logger from '@server/logger';
import axios from 'axios';
import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
import { createHash } from 'crypto';
import { promises } from 'fs';
import path, { join } from 'path';
type ImageResponse = {
meta: {
revalidateAfter: number;
curRevalidate: number;
isStale: boolean;
etag: string;
extension: string;
cacheKey: string;
cacheMiss: boolean;
};
imageBuffer: Buffer;
};
class ImageProxy {
public static async clearCache(key: string) {
let deletedImages = 0;
const cacheDirectory = path.join(
__dirname,
'../../config/cache/images/',
key
);
const files = await promises.readdir(cacheDirectory);
for (const file of files) {
const filePath = path.join(cacheDirectory, file);
const stat = await promises.lstat(filePath);
if (stat.isDirectory()) {
const imageFiles = await promises.readdir(filePath);
for (const imageFile of imageFiles) {
const [, expireAtSt] = imageFile.split('.');
const expireAt = Number(expireAtSt);
const now = Date.now();
if (now > expireAt) {
await promises.rm(path.join(filePath, imageFile));
deletedImages += 1;
}
}
}
}
logger.info(`Cleared ${deletedImages} stale image(s) from cache`, {
label: 'Image Cache',
});
}
public static async getImageStats(
key: string
): Promise<{ size: number; imageCount: number }> {
const cacheDirectory = path.join(
__dirname,
'../../config/cache/images/',
key
);
const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory);
const imageCount = await ImageProxy.getImageCount(cacheDirectory);
return {
size: imageTotalSize,
imageCount,
};
}
private static async getDirectorySize(dir: string): Promise<number> {
const files = await promises.readdir(dir, {
withFileTypes: true,
});
const paths = files.map(async (file) => {
const path = join(dir, file.name);
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
if (file.isFile()) {
const { size } = await promises.stat(path);
return size;
}
return 0;
});
return (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
}
private static async getImageCount(dir: string) {
const files = await promises.readdir(dir);
return files.length;
}
private axios;
private cacheVersion;
private key;
constructor(
key: string,
baseUrl: string,
options: {
cacheVersion?: number;
rateLimitOptions?: rateLimitOptions;
} = {}
) {
this.cacheVersion = options.cacheVersion ?? 1;
this.key = key;
this.axios = axios.create({
baseURL: baseUrl,
});
if (options.rateLimitOptions) {
this.axios = rateLimit(this.axios, options.rateLimitOptions);
}
}
public async getImage(path: string): Promise<ImageResponse> {
const cacheKey = this.getCacheKey(path);
const imageResponse = await this.get(cacheKey);
if (!imageResponse) {
const newImage = await this.set(path, cacheKey);
if (!newImage) {
throw new Error('Failed to load image');
}
return newImage;
}
// If the image is stale, we will revalidate it in the background.
if (imageResponse.meta.isStale) {
this.set(path, cacheKey);
}
return imageResponse;
}
private async get(cacheKey: string): Promise<ImageResponse | null> {
try {
const directory = join(this.getCacheDirectory(), cacheKey);
const files = await promises.readdir(directory);
const now = Date.now();
for (const file of files) {
const [maxAgeSt, expireAtSt, etag, extension] = file.split('.');
const buffer = await promises.readFile(join(directory, file));
const expireAt = Number(expireAtSt);
const maxAge = Number(maxAgeSt);
return {
meta: {
curRevalidate: maxAge,
revalidateAfter: maxAge * 1000 + now,
isStale: now > expireAt,
etag,
extension,
cacheKey,
cacheMiss: false,
},
imageBuffer: buffer,
};
}
} catch (e) {
// No files. Treat as empty cache.
}
return null;
}
private async set(
path: string,
cacheKey: string
): Promise<ImageResponse | null> {
try {
const directory = join(this.getCacheDirectory(), cacheKey);
const response = await this.axios.get(path, {
responseType: 'arraybuffer',
});
const buffer = Buffer.from(response.data, 'binary');
const extension = path.split('.').pop() ?? '';
const maxAge = Number(response.headers['cache-control'].split('=')[1]);
const expireAt = Date.now() + maxAge * 1000;
const etag = response.headers.etag.replace(/"/g, '');
await this.writeToCacheDir(
directory,
extension,
maxAge,
expireAt,
buffer,
etag
);
return {
meta: {
curRevalidate: maxAge,
revalidateAfter: expireAt,
isStale: false,
etag,
extension,
cacheKey,
cacheMiss: true,
},
imageBuffer: buffer,
};
} catch (e) {
logger.debug('Something went wrong caching image.', {
label: 'Image Cache',
errorMessage: e.message,
});
return null;
}
}
private async writeToCacheDir(
dir: string,
extension: string,
maxAge: number,
expireAt: number,
buffer: Buffer,
etag: string
) {
const filename = join(dir, `${maxAge}.${expireAt}.${etag}.${extension}`);
await promises.rm(dir, { force: true, recursive: true }).catch(() => {
// do nothing
});
await promises.mkdir(dir, { recursive: true });
await promises.writeFile(filename, buffer);
}
private getCacheKey(path: string) {
return this.getHash([this.key, this.cacheVersion, path]);
}
private getHash(items: (string | number | Buffer)[]) {
const hash = createHash('sha256');
for (const item of items) {
if (typeof item === 'number') hash.update(String(item));
else {
hash.update(item);
}
}
// See https://en.wikipedia.org/wiki/Base64#Filenames
return hash.digest('base64').replace(/\//g, '-');
}
private getCacheDirectory() {
return path.join(__dirname, '../../config/cache/images/', this.key);
}
}
export default ImageProxy;

View File

@@ -38,7 +38,7 @@ export interface PlexSettings {
export interface JellyfinSettings { export interface JellyfinSettings {
name: string; name: string;
hostname?: string; hostname: string;
externalHostname?: string; externalHostname?: string;
libraries: Library[]; libraries: Library[];
serverId: string; serverId: string;
@@ -263,7 +263,8 @@ export type JobId =
| 'download-sync' | 'download-sync'
| 'download-sync-reset' | 'download-sync-reset'
| 'jellyfin-recently-added-sync' | 'jellyfin-recently-added-sync'
| 'jellyfin-full-sync'; | 'jellyfin-full-sync'
| 'image-cache-cleanup';
interface AllSettings { interface AllSettings {
clientId: string; clientId: string;
@@ -446,6 +447,9 @@ class Settings {
'jellyfin-full-sync': { 'jellyfin-full-sync': {
schedule: '0 0 3 * * *', schedule: '0 0 3 * * *',
}, },
'image-cache-cleanup': {
schedule: '0 0 5 * * *',
},
}, },
}; };
if (initialSettings) { if (initialSettings) {

View File

@@ -89,13 +89,28 @@ authRoutes.post('/plex', async (req, res, next) => {
await userRepository.save(user); await userRepository.save(user);
} else { } else {
const mainUser = await userRepository.findOneOrFail({ const mainUser = await userRepository.findOneOrFail({
select: { id: true, plexToken: true, plexId: true }, select: { id: true, plexToken: true, plexId: true, email: true },
where: { id: 1 }, where: { id: 1 },
}); });
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
if (!account.id) {
logger.error('Plex ID was missing from Plex.tv response', {
label: 'API',
ip: req.ip,
email: account.email,
plexUsername: account.username,
});
return next({
status: 500,
message: 'Something went wrong. Try again.',
});
}
if ( if (
account.id === mainUser.plexId || account.id === mainUser.plexId ||
(account.email === mainUser.email && !mainUser.plexId) ||
(await mainPlexTv.checkUserAccess(account.id)) (await mainPlexTv.checkUserAccess(account.id))
) { ) {
if (user) { if (user) {
@@ -226,7 +241,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
const hostname = const hostname =
settings.jellyfin.hostname !== '' settings.jellyfin.hostname !== ''
? settings.jellyfin.hostname ? settings.jellyfin.hostname
: body.hostname; : body.hostname ?? '';
const { externalHostname } = getSettings().jellyfin; const { externalHostname } = getSettings().jellyfin;
// Try to find deviceId that corresponds to jellyfin user, else generate a new one // Try to find deviceId that corresponds to jellyfin user, else generate a new one
@@ -249,8 +264,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
? externalHostname ? externalHostname
: hostname; : hostname;
jellyfinHost = jellyfinHost!.endsWith('/') jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost!.slice(0, -1) ? jellyfinHost.slice(0, -1)
: jellyfinHost; : jellyfinHost;
const account = await jellyfinserver.login(body.username, body.password); const account = await jellyfinserver.login(body.username, body.password);

View File

@@ -0,0 +1,39 @@
import ImageProxy from '@server/lib/imageproxy';
import logger from '@server/logger';
import { Router } from 'express';
const router = Router();
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});
/**
* Image Proxy
*/
router.get('/*', async (req, res) => {
const imagePath = req.path.replace('/image', '');
try {
const imageData = await tmdbImageProxy.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 image', {
imagePath,
errorMessage: e.message,
});
res.status(500).send();
}
});
export default router;

View File

@@ -16,9 +16,10 @@ import { jobJellyfinFullSync } from '@server/job/jellyfinsync';
import { scheduledJobs } from '@server/job/schedule'; import { scheduledJobs } from '@server/job/schedule';
import type { AvailableCacheIds } from '@server/lib/cache'; import type { AvailableCacheIds } from '@server/lib/cache';
import cacheManager from '@server/lib/cache'; import cacheManager from '@server/lib/cache';
import ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import { plexFullScanner } from '@server/lib/scanners/plex'; import { plexFullScanner } from '@server/lib/scanners/plex';
import type { Library, MainSettings } from '@server/lib/settings'; import type { JobId, Library, MainSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
@@ -312,8 +313,8 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
? externalHostname ? externalHostname
: hostname; : hostname;
jellyfinHost = jellyfinHost!.endsWith('/') jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost!.slice(0, -1) ? jellyfinHost.slice(0, -1)
: jellyfinHost; : jellyfinHost;
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
@@ -604,7 +605,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
}); });
}); });
settingsRoutes.post<{ jobId: string }>( settingsRoutes.post<{ jobId: JobId }>(
'/jobs/:jobId/cancel', '/jobs/:jobId/cancel',
(req, res, next) => { (req, res, next) => {
const scheduledJob = scheduledJobs.find( const scheduledJob = scheduledJobs.find(
@@ -631,7 +632,7 @@ settingsRoutes.post<{ jobId: string }>(
} }
); );
settingsRoutes.post<{ jobId: string }>( settingsRoutes.post<{ jobId: JobId }>(
'/jobs/:jobId/schedule', '/jobs/:jobId/schedule',
(req, res, next) => { (req, res, next) => {
const scheduledJob = scheduledJobs.find( const scheduledJob = scheduledJobs.find(
@@ -666,16 +667,23 @@ settingsRoutes.post<{ jobId: string }>(
} }
); );
settingsRoutes.get('/cache', (req, res) => { settingsRoutes.get('/cache', async (_req, res) => {
const caches = cacheManager.getAllCaches(); const cacheManagerCaches = cacheManager.getAllCaches();
return res.status(200).json( const apiCaches = Object.values(cacheManagerCaches).map((cache) => ({
Object.values(caches).map((cache) => ({ id: cache.id,
id: cache.id, name: cache.name,
name: cache.name, stats: cache.getStats(),
stats: cache.getStats(), }));
}))
); const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
return res.status(200).json({
apiCaches,
imageCache: {
tmdb: tmdbImageCache,
},
});
}); });
settingsRoutes.post<{ cacheId: AvailableCacheIds }>( settingsRoutes.post<{ cacheId: AvailableCacheIds }>(

View File

@@ -502,8 +502,8 @@ router.post(
? externalHostname ? externalHostname
: hostname; : hostname;
jellyfinHost = jellyfinHost!.endsWith('/') jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost!.slice(0, -1) ? jellyfinHost.slice(0, -1)
: jellyfinHost; : jellyfinHost;
jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const jellyfinUsers = await jellyfinClient.getUsers(); const jellyfinUsers = await jellyfinClient.getUsers();

View File

@@ -1,18 +1,27 @@
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import type { ImageProps } from 'next/image'; import type { ImageLoader, ImageProps } from 'next/image';
import Image from 'next/image'; import Image from 'next/image';
const imageLoader: ImageLoader = ({ src }) => src;
/** /**
* The CachedImage component should be used wherever * The CachedImage component should be used wherever
* we want to offer the option to locally cache images. * we want to offer the option to locally cache images.
*
* It uses the `next/image` Image component but overrides
* the `unoptimized` prop based on the application setting `cacheImages`.
**/ **/
const CachedImage = (props: ImageProps) => { const CachedImage = ({ src, ...props }: ImageProps) => {
const { currentSettings } = useSettings(); const { currentSettings } = useSettings();
return <Image unoptimized={!currentSettings.cacheImages} {...props} />; let imageUrl = src;
if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
const parsedUrl = new URL(imageUrl);
if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) {
imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy');
}
}
return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />;
}; };
export default CachedImage; export default CachedImage;

View File

@@ -1,3 +1,4 @@
import CachedImage from '@app/components/Common/CachedImage';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
@@ -30,11 +31,15 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => {
role="link" role="link"
tabIndex={0} tabIndex={0}
> >
<img <div className="relative h-full w-full">
src={image} <CachedImage
alt={name} src={image}
className="relative z-40 max-h-full max-w-full" alt={name}
/> className="relative z-40 h-full w-full"
layout="fill"
objectFit="contain"
/>
</div>
<div <div
className={`absolute bottom-0 left-0 right-0 z-0 h-12 rounded-b-xl bg-gradient-to-t ${ className={`absolute bottom-0 left-0 right-0 z-0 h-12 rounded-b-xl bg-gradient-to-t ${
isHovered ? 'from-gray-800' : 'from-gray-900' isHovered ? 'from-gray-800' : 'from-gray-900'

View File

@@ -7,6 +7,7 @@ import PageTitle from '@app/components/Common/PageTitle';
import IssueComment from '@app/components/IssueDetails/IssueComment'; import IssueComment from '@app/components/IssueDetails/IssueComment';
import IssueDescription from '@app/components/IssueDetails/IssueDescription'; import IssueDescription from '@app/components/IssueDetails/IssueDescription';
import { issueOptions } from '@app/components/IssueModal/constants'; import { issueOptions } from '@app/components/IssueModal/constants';
import useDeepLinks from '@app/hooks/useDeepLinks';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
@@ -91,6 +92,13 @@ const IssueDetails = () => {
: null : null
); );
const { mediaUrl, mediaUrl4k } = useDeepLinks({
mediaUrl: data?.mediaInfo?.mediaUrl,
mediaUrl4k: data?.mediaInfo?.mediaUrl4k,
iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl,
iOSPlexUrl4k: data?.mediaInfo?.iOSPlexUrl4k,
});
const CommentSchema = Yup.object().shape({ const CommentSchema = Yup.object().shape({
message: Yup.string().required(), message: Yup.string().required(),
}); });
@@ -359,7 +367,7 @@ const IssueDetails = () => {
{issueData?.media.mediaUrl && ( {issueData?.media.mediaUrl && (
<Button <Button
as="a" as="a"
href={issueData?.media.mediaUrl} href={mediaUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="w-full" className="w-full"
@@ -405,7 +413,7 @@ const IssueDetails = () => {
{issueData?.media.mediaUrl4k && ( {issueData?.media.mediaUrl4k && (
<Button <Button
as="a" as="a"
href={issueData?.media.mediaUrl4k} href={mediaUrl4k}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="w-full" className="w-full"
@@ -621,7 +629,7 @@ const IssueDetails = () => {
{issueData?.media.mediaUrl && ( {issueData?.media.mediaUrl && (
<Button <Button
as="a" as="a"
href={issueData?.media.mediaUrl} href={mediaUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="w-full" className="w-full"
@@ -667,7 +675,7 @@ const IssueDetails = () => {
{issueData?.media.mediaUrl4k && ( {issueData?.media.mediaUrl4k && (
<Button <Button
as="a" as="a"
href={issueData?.media.mediaUrl4k} href={mediaUrl4k}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="w-full" className="w-full"

View File

@@ -1,6 +1,8 @@
import TitleCard from '@app/components/TitleCard';
import { ArrowCircleRightIcon } from '@heroicons/react/solid'; import { ArrowCircleRightIcon } from '@heroicons/react/solid';
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 { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({
@@ -15,6 +17,18 @@ interface ShowMoreCardProps {
const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => { const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => {
const intl = useIntl(); const intl = useIntl();
const [isHovered, setHovered] = useState(false); const [isHovered, setHovered] = useState(false);
const { ref, inView } = useInView({
triggerOnce: true,
});
if (!inView) {
return (
<div ref={ref}>
<TitleCard.Placeholder />
</div>
);
}
return ( return (
<Link href={url}> <Link href={url}>
<a <a

View File

@@ -18,6 +18,7 @@ import PersonCard from '@app/components/PersonCard';
import RequestButton from '@app/components/RequestButton'; import RequestButton from '@app/components/RequestButton';
import Slider from '@app/components/Slider'; import Slider from '@app/components/Slider';
import StatusBadge from '@app/components/StatusBadge'; import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks';
import useLocale from '@app/hooks/useLocale'; import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
@@ -129,31 +130,12 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
setShowManager(router.query.manage == '1' ? true : false); setShowManager(router.query.manage == '1' ? true : false);
}, [router.query.manage]); }, [router.query.manage]);
const [plexUrl, setPlexUrl] = useState(data?.mediaInfo?.mediaUrl); const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
const [plexUrl4k, setPlexUrl4k] = useState(data?.mediaInfo?.mediaUrl4k); mediaUrl: data?.mediaInfo?.mediaUrl,
mediaUrl4k: data?.mediaInfo?.mediaUrl4k,
useEffect(() => { iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl,
if (data) { iOSPlexUrl4k: data?.mediaInfo?.iOSPlexUrl4k,
if ( });
settings.currentSettings.mediaServerType === MediaServerType.PLEX &&
(/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.userAgent === 'MacIntel' && navigator.maxTouchPoints > 1))
) {
setPlexUrl(data.mediaInfo?.iOSPlexUrl);
setPlexUrl4k(data.mediaInfo?.iOSPlexUrl4k);
} else {
setPlexUrl(data.mediaInfo?.mediaUrl);
setPlexUrl4k(data.mediaInfo?.mediaUrl4k);
}
}
}, [
data,
data?.mediaInfo?.iOSPlexUrl,
data?.mediaInfo?.iOSPlexUrl4k,
data?.mediaInfo?.mediaUrl,
data?.mediaInfo?.mediaUrl4k,
settings.currentSettings.mediaServerType,
]);
if (!data && !error) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
@@ -378,7 +360,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
} }
tmdbId={data.mediaInfo?.tmdbId} tmdbId={data.mediaInfo?.tmdbId}
mediaType="movie" mediaType="movie"
plexUrl={plexUrl} plexUrl={plexUrl4k}
serviceUrl={data.mediaInfo?.serviceUrl4k} serviceUrl={data.mediaInfo?.serviceUrl4k}
/> />
)} )}

View File

@@ -1,15 +1,17 @@
import { RefreshIcon } from '@heroicons/react/outline'; import { RefreshIcon } from '@heroicons/react/outline';
import Router from 'next/router'; import { useRouter } from 'next/router';
import PR from 'pulltorefreshjs'; import PR from 'pulltorefreshjs';
import { useEffect } from 'react'; import { useEffect } from 'react';
import ReactDOMServer from 'react-dom/server'; import ReactDOMServer from 'react-dom/server';
const PullToRefresh: React.FC = () => { const PullToRefresh = () => {
const router = useRouter();
useEffect(() => { useEffect(() => {
PR.init({ PR.init({
mainElement: '#pull-to-refresh', mainElement: '#pull-to-refresh',
onRefresh() { onRefresh() {
Router.reload(); router.reload();
}, },
iconArrow: ReactDOMServer.renderToString( iconArrow: ReactDOMServer.renderToString(
<div className="p-2"> <div className="p-2">
@@ -28,11 +30,14 @@ const PullToRefresh: React.FC = () => {
instructionsReleaseToRefresh: ReactDOMServer.renderToString(<div />), instructionsReleaseToRefresh: ReactDOMServer.renderToString(<div />),
instructionsRefreshing: ReactDOMServer.renderToString(<div />), instructionsRefreshing: ReactDOMServer.renderToString(<div />),
distReload: 60, distReload: 60,
distIgnore: 15,
shouldPullToRefresh: () =>
!window.scrollY && document.body.style.overflow !== 'hidden',
}); });
return () => { return () => {
PR.destroyAll(); PR.destroyAll();
}; };
}, []); }, [router]);
return <div id="pull-to-refresh"></div>; return <div id="pull-to-refresh"></div>;
}; };

View File

@@ -4,6 +4,7 @@ import CachedImage from '@app/components/Common/CachedImage';
import Tooltip from '@app/components/Common/Tooltip'; import Tooltip from '@app/components/Common/Tooltip';
import RequestModal from '@app/components/RequestModal'; import RequestModal from '@app/components/RequestModal';
import StatusBadge from '@app/components/StatusBadge'; import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import { withProperties } from '@app/utils/typeHelpers'; import { withProperties } from '@app/utils/typeHelpers';
@@ -61,6 +62,13 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
const { hasPermission } = useUser(); const { hasPermission } = useUser();
const intl = useIntl(); const intl = useIntl();
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
mediaUrl: requestData?.media?.mediaUrl,
mediaUrl4k: requestData?.media?.mediaUrl4k,
iOSPlexUrl: requestData?.media?.iOSPlexUrl,
iOSPlexUrl4k: requestData?.media?.iOSPlexUrl4k,
});
const deleteRequest = async () => { const deleteRequest = async () => {
await axios.delete(`/api/v1/media/${requestData?.media.id}`); await axios.delete(`/api/v1/media/${requestData?.media.id}`);
mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded'); mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded');
@@ -138,11 +146,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
).length > 0 ).length > 0
} }
is4k={requestData.is4k} is4k={requestData.is4k}
plexUrl={ plexUrl={requestData.is4k ? plexUrl4k : plexUrl}
requestData.is4k
? requestData.media.mediaUrl4k
: requestData.media.mediaUrl
}
serviceUrl={ serviceUrl={
requestData.is4k requestData.is4k
? requestData.media.serviceUrl4k ? requestData.media.serviceUrl4k
@@ -217,6 +221,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
fallbackData: request, fallbackData: request,
}); });
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
mediaUrl: requestData?.media?.mediaUrl,
mediaUrl4k: requestData?.media?.mediaUrl4k,
iOSPlexUrl: requestData?.media?.iOSPlexUrl,
iOSPlexUrl4k: requestData?.media?.iOSPlexUrl4k,
});
const modifyRequest = async (type: 'approve' | 'decline') => { const modifyRequest = async (type: 'approve' | 'decline') => {
const response = await axios.post(`/api/v1/request/${request.id}/${type}`); const response = await axios.post(`/api/v1/request/${request.id}/${type}`);
@@ -396,11 +407,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
is4k={requestData.is4k} is4k={requestData.is4k}
tmdbId={requestData.media.tmdbId} tmdbId={requestData.media.tmdbId}
mediaType={requestData.type} mediaType={requestData.type}
plexUrl={ plexUrl={requestData.is4k ? plexUrl4k : plexUrl}
requestData.is4k
? requestData.media.mediaUrl4k
: requestData.media.mediaUrl
}
serviceUrl={ serviceUrl={
requestData.is4k requestData.is4k
? requestData.media.serviceUrl4k ? requestData.media.serviceUrl4k

View File

@@ -4,6 +4,7 @@ import CachedImage from '@app/components/Common/CachedImage';
import ConfirmButton from '@app/components/Common/ConfirmButton'; import ConfirmButton from '@app/components/Common/ConfirmButton';
import RequestModal from '@app/components/RequestModal'; import RequestModal from '@app/components/RequestModal';
import StatusBadge from '@app/components/StatusBadge'; import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import { import {
@@ -61,6 +62,13 @@ const RequestItemError = ({
revalidateList(); revalidateList();
}; };
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
mediaUrl: requestData?.media?.mediaUrl,
mediaUrl4k: requestData?.media?.mediaUrl4k,
iOSPlexUrl: requestData?.media?.iOSPlexUrl,
iOSPlexUrl4k: requestData?.media?.iOSPlexUrl4k,
});
return ( return (
<div className="flex h-64 w-full flex-col justify-center rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-red-500 xl:h-28 xl:flex-row"> <div className="flex h-64 w-full flex-col justify-center rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-red-500 xl:h-28 xl:flex-row">
<div className="flex w-full flex-col justify-between overflow-hidden sm:flex-row"> <div className="flex w-full flex-col justify-between overflow-hidden sm:flex-row">
@@ -130,11 +138,7 @@ const RequestItemError = ({
).length > 0 ).length > 0
} }
is4k={requestData.is4k} is4k={requestData.is4k}
plexUrl={ plexUrl={requestData.is4k ? plexUrl4k : plexUrl}
requestData.is4k
? requestData.media.mediaUrl4k
: requestData.media.mediaUrl
}
serviceUrl={ serviceUrl={
requestData.is4k requestData.is4k
? requestData.media.serviceUrl4k ? requestData.media.serviceUrl4k
@@ -316,6 +320,13 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
} }
}; };
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
mediaUrl: requestData?.media?.mediaUrl,
mediaUrl4k: requestData?.media?.mediaUrl4k,
iOSPlexUrl: requestData?.media?.iOSPlexUrl,
iOSPlexUrl4k: requestData?.media?.iOSPlexUrl4k,
});
if (!title && !error) { if (!title && !error) {
return ( return (
<div <div
@@ -462,11 +473,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
is4k={requestData.is4k} is4k={requestData.is4k}
tmdbId={requestData.media.tmdbId} tmdbId={requestData.media.tmdbId}
mediaType={requestData.type} mediaType={requestData.type}
plexUrl={ plexUrl={requestData.is4k ? plexUrl4k : plexUrl}
requestData.is4k
? requestData.media.mediaUrl4k
: requestData.media.mediaUrl
}
serviceUrl={ serviceUrl={
requestData.is4k requestData.is4k
? requestData.media.serviceUrl4k ? requestData.media.serviceUrl4k

View File

@@ -13,7 +13,10 @@ import { Transition } from '@headlessui/react';
import { PlayIcon, StopIcon, TrashIcon } from '@heroicons/react/outline'; import { PlayIcon, StopIcon, TrashIcon } from '@heroicons/react/outline';
import { PencilIcon } from '@heroicons/react/solid'; import { PencilIcon } from '@heroicons/react/solid';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import type { CacheItem } from '@server/interfaces/api/settingsInterfaces'; import type {
CacheItem,
CacheResponse,
} from '@server/interfaces/api/settingsInterfaces';
import type { JobId } from '@server/lib/settings'; import type { JobId } from '@server/lib/settings';
import axios from 'axios'; import axios from 'axios';
import cronstrue from 'cronstrue/i18n'; import cronstrue from 'cronstrue/i18n';
@@ -58,6 +61,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
'sonarr-scan': 'Sonarr Scan', 'sonarr-scan': 'Sonarr Scan',
'download-sync': 'Download Sync', 'download-sync': 'Download Sync',
'download-sync-reset': 'Download Sync Reset', 'download-sync-reset': 'Download Sync Reset',
'image-cache-cleanup': 'Image Cache Cleanup',
editJobSchedule: 'Modify Job', editJobSchedule: 'Modify Job',
jobScheduleEditSaved: 'Job edited successfully!', jobScheduleEditSaved: 'Job edited successfully!',
jobScheduleEditFailed: 'Something went wrong while saving the job.', jobScheduleEditFailed: 'Something went wrong while saving the job.',
@@ -67,6 +71,11 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}', 'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
editJobScheduleSelectorMinutes: editJobScheduleSelectorMinutes:
'Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}', 'Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}',
imagecache: 'Image Cache',
imagecacheDescription:
'When enabled in settings, Overseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
imagecachecount: 'Images Cached',
imagecachesize: 'Total Cache Size',
}); });
interface Job { interface Job {
@@ -132,7 +141,8 @@ const SettingsJobs = () => {
} = useSWR<Job[]>('/api/v1/settings/jobs', { } = useSWR<Job[]>('/api/v1/settings/jobs', {
refreshInterval: 5000, refreshInterval: 5000,
}); });
const { data: cacheData, mutate: cacheRevalidate } = useSWR<CacheItem[]>( const { data: appData } = useSWR('/api/v1/status/appdata');
const { data: cacheData, mutate: cacheRevalidate } = useSWR<CacheResponse>(
'/api/v1/settings/cache', '/api/v1/settings/cache',
{ {
refreshInterval: 10000, refreshInterval: 10000,
@@ -435,7 +445,7 @@ const SettingsJobs = () => {
</tr> </tr>
</thead> </thead>
<Table.TBody> <Table.TBody>
{cacheData {cacheData?.apiCaches
?.filter( ?.filter(
(cache) => (cache) =>
!( !(
@@ -465,6 +475,41 @@ const SettingsJobs = () => {
</Table.TBody> </Table.TBody>
</Table> </Table>
</div> </div>
<div>
<h3 className="heading">{intl.formatMessage(messages.imagecache)}</h3>
<p className="description">
{intl.formatMessage(messages.imagecacheDescription, {
code: (msg: React.ReactNode) => (
<code className="bg-opacity-50">{msg}</code>
),
appDataPath: appData ? appData.appDataPath : '/app/config',
})}
</p>
</div>
<div className="section">
<Table>
<thead>
<tr>
<Table.TH>{intl.formatMessage(messages.cachename)}</Table.TH>
<Table.TH>
{intl.formatMessage(messages.imagecachecount)}
</Table.TH>
<Table.TH>{intl.formatMessage(messages.imagecachesize)}</Table.TH>
</tr>
</thead>
<Table.TBody>
<tr>
<Table.TD>The Movie Database (tmdb)</Table.TD>
<Table.TD>
{intl.formatNumber(cacheData?.imageCache.tmdb.imageCount ?? 0)}
</Table.TD>
<Table.TD>
{formatBytes(cacheData?.imageCache.tmdb.size ?? 0)}
</Table.TD>
</tr>
</Table.TBody>
</Table>
</div>
</> </>
); );
}; };

View File

@@ -46,7 +46,7 @@ const messages = defineMessages({
'Do NOT enable this setting unless you understand what you are doing!', 'Do NOT enable this setting unless you understand what you are doing!',
cacheImages: 'Enable Image Caching', cacheImages: 'Enable Image Caching',
cacheImagesTip: cacheImagesTip:
'Cache and serve optimized images (requires a significant amount of disk space)', 'Cache externally sourced images (requires a significant amount of disk space)',
trustProxy: 'Enable Proxy Support', trustProxy: 'Enable Proxy Support',
trustProxyTip: trustProxyTip:
'Allow Overseerr to correctly register client IP addresses behind a proxy', 'Allow Overseerr to correctly register client IP addresses behind a proxy',

View File

@@ -22,6 +22,7 @@ import RequestModal from '@app/components/RequestModal';
import Slider from '@app/components/Slider'; import Slider from '@app/components/Slider';
import StatusBadge from '@app/components/StatusBadge'; import StatusBadge from '@app/components/StatusBadge';
import Season from '@app/components/TvDetails/Season'; import Season from '@app/components/TvDetails/Season';
import useDeepLinks from '@app/hooks/useDeepLinks';
import useLocale from '@app/hooks/useLocale'; import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
@@ -125,31 +126,12 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
setShowManager(router.query.manage == '1' ? true : false); setShowManager(router.query.manage == '1' ? true : false);
}, [router.query.manage]); }, [router.query.manage]);
const [plexUrl, setPlexUrl] = useState(data?.mediaInfo?.mediaUrl); const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
const [plexUrl4k, setPlexUrl4k] = useState(data?.mediaInfo?.mediaUrl4k); mediaUrl: data?.mediaInfo?.mediaUrl,
mediaUrl4k: data?.mediaInfo?.mediaUrl4k,
useEffect(() => { iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl,
if (data) { iOSPlexUrl4k: data?.mediaInfo?.iOSPlexUrl4k,
if ( });
settings.currentSettings.mediaServerType === MediaServerType.PLEX &&
(/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.userAgent === 'MacIntel' && navigator.maxTouchPoints > 1))
) {
setPlexUrl(data.mediaInfo?.iOSPlexUrl);
setPlexUrl4k(data.mediaInfo?.iOSPlexUrl4k);
} else {
setPlexUrl(data.mediaInfo?.mediaUrl);
setPlexUrl4k(data.mediaInfo?.mediaUrl4k);
}
}
}, [
data,
data?.mediaInfo?.iOSPlexUrl,
data?.mediaInfo?.iOSPlexUrl4k,
data?.mediaInfo?.mediaUrl,
data?.mediaInfo?.mediaUrl4k,
settings.currentSettings.mediaServerType,
]);
if (!data && !error) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
@@ -984,9 +966,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
tvdbId={data.externalIds.tvdbId} tvdbId={data.externalIds.tvdbId}
imdbId={data.externalIds.imdbId} imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url} rtUrl={ratingData?.url}
mediaUrl={ mediaUrl={plexUrl ?? plexUrl4k}
data.mediaInfo?.mediaUrl ?? data.mediaInfo?.mediaUrl4k
}
/> />
</div> </div>
</div> </div>

View File

@@ -10,6 +10,7 @@ export type AvailableLocale =
| 'el' | 'el'
| 'es' | 'es'
| 'fr' | 'fr'
| 'hr'
| 'hu' | 'hu'
| 'it' | 'it'
| 'ja' | 'ja'
@@ -60,6 +61,10 @@ export const availableLanguages: AvailableLanguageObject = {
code: 'fr', code: 'fr',
display: 'Français', display: 'Français',
}, },
hr: {
code: 'hr',
display: 'Hrvatski',
},
it: { it: {
code: 'it', code: 'it',
display: 'Italiano', display: 'Italiano',

45
src/hooks/useDeepLinks.ts Normal file
View File

@@ -0,0 +1,45 @@
import useSettings from '@app/hooks/useSettings';
import { MediaServerType } from '@server/constants/server';
import { useEffect, useState } from 'react';
interface useDeepLinksProps {
mediaUrl?: string;
mediaUrl4k?: string;
iOSPlexUrl?: string;
iOSPlexUrl4k?: string;
}
const useDeepLinks = ({
mediaUrl,
mediaUrl4k,
iOSPlexUrl,
iOSPlexUrl4k,
}: useDeepLinksProps) => {
const [returnedMediaUrl, setReturnedMediaUrl] = useState(mediaUrl);
const [returnedMediaUrl4k, setReturnedMediaUrl4k] = useState(mediaUrl4k);
const settings = useSettings();
useEffect(() => {
if (
settings.currentSettings.mediaServerType === MediaServerType.PLEX &&
(/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.userAgent === 'MacIntel' && navigator.maxTouchPoints > 1))
) {
setReturnedMediaUrl(iOSPlexUrl);
setReturnedMediaUrl4k(iOSPlexUrl4k);
} else {
setReturnedMediaUrl(mediaUrl);
setReturnedMediaUrl4k(mediaUrl4k);
}
}, [
iOSPlexUrl,
iOSPlexUrl4k,
mediaUrl,
mediaUrl4k,
settings.currentSettings.mediaServerType,
]);
return { mediaUrl: returnedMediaUrl, mediaUrl4k: returnedMediaUrl4k };
};
export default useDeepLinks;

View File

@@ -649,6 +649,11 @@
"components.Settings.SettingsJobsCache.flushcache": "Flush Cache", "components.Settings.SettingsJobsCache.flushcache": "Flush Cache",
"components.Settings.SettingsJobsCache.jelly-recently-added-scan": "Jellyfin Recently Added Scan", "components.Settings.SettingsJobsCache.jelly-recently-added-scan": "Jellyfin Recently Added Scan",
"components.Settings.SettingsJobsCache.jellyfin-full-scan": "Jellyfin Full Library Scan", "components.Settings.SettingsJobsCache.jellyfin-full-scan": "Jellyfin Full Library Scan",
"components.Settings.SettingsJobsCache.image-cache-cleanup": "Image Cache Cleanup",
"components.Settings.SettingsJobsCache.imagecache": "Image Cache",
"components.Settings.SettingsJobsCache.imagecacheDescription": "When enabled in settings, Overseerr 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>.",
"components.Settings.SettingsJobsCache.imagecachecount": "Images Cached",
"components.Settings.SettingsJobsCache.imagecachesize": "Total Cache Size",
"components.Settings.SettingsJobsCache.jobScheduleEditFailed": "Something went wrong while saving the job.", "components.Settings.SettingsJobsCache.jobScheduleEditFailed": "Something went wrong while saving the job.",
"components.Settings.SettingsJobsCache.jobScheduleEditSaved": "Job edited successfully!", "components.Settings.SettingsJobsCache.jobScheduleEditSaved": "Job edited successfully!",
"components.Settings.SettingsJobsCache.jobcancelled": "{jobname} canceled.", "components.Settings.SettingsJobsCache.jobcancelled": "{jobname} canceled.",
@@ -759,7 +764,7 @@
"components.Settings.applicationTitle": "Application Title", "components.Settings.applicationTitle": "Application Title",
"components.Settings.applicationurl": "Application URL", "components.Settings.applicationurl": "Application URL",
"components.Settings.cacheImages": "Enable Image Caching", "components.Settings.cacheImages": "Enable Image Caching",
"components.Settings.cacheImagesTip": "Cache and serve optimized images (requires a significant amount of disk space)", "components.Settings.cacheImagesTip": "Cache externally sourced images (requires a significant amount of disk space)",
"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.csrfProtection": "Enable CSRF Protection", "components.Settings.csrfProtection": "Enable CSRF Protection",

View File

@@ -43,6 +43,8 @@ const loadLocaleData = (locale: AvailableLocale): Promise<any> => {
return import('../i18n/locale/es.json'); return import('../i18n/locale/es.json');
case 'fr': case 'fr':
return import('../i18n/locale/fr.json'); return import('../i18n/locale/fr.json');
case 'hr':
return import('../i18n/locale/hr.json');
case 'hu': case 'hu':
return import('../i18n/locale/hu.json'); return import('../i18n/locale/hu.json');
case 'it': case 'it':