feat: add option to cache images locally (#1213)

pull/1214/head
sct 3 years ago committed by GitHub
parent dfd4ff9229
commit 0ca3d43749
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -2,6 +2,9 @@ module.exports = {
env: { env: {
commitTag: process.env.COMMIT_TAG || 'local', commitTag: process.env.COMMIT_TAG || 'local',
}, },
images: {
domains: ['image.tmdb.org'],
},
webpack(config) { webpack(config) {
config.module.rules.push({ config.module.rules.push({
test: /\.svg$/, test: /\.svg$/,

@ -28,6 +28,7 @@ export interface PublicSettingsResponse {
region: string; region: string;
originalLanguage: string; originalLanguage: string;
partialRequestsEnabled: boolean; partialRequestsEnabled: boolean;
cacheImages: boolean;
} }
export interface CacheItem { export interface CacheItem {

@ -66,6 +66,7 @@ export interface MainSettings {
applicationTitle: string; applicationTitle: string;
applicationUrl: string; applicationUrl: string;
csrfProtection: boolean; csrfProtection: boolean;
cacheImages: boolean;
defaultPermissions: number; defaultPermissions: number;
hideAvailable: boolean; hideAvailable: boolean;
localLogin: boolean; localLogin: boolean;
@ -88,6 +89,7 @@ interface FullPublicSettings extends PublicSettings {
region: string; region: string;
originalLanguage: string; originalLanguage: string;
partialRequestsEnabled: boolean; partialRequestsEnabled: boolean;
cacheImages: boolean;
} }
export interface NotificationAgentConfig { export interface NotificationAgentConfig {
@ -195,6 +197,7 @@ class Settings {
applicationTitle: 'Overseerr', applicationTitle: 'Overseerr',
applicationUrl: '', applicationUrl: '',
csrfProtection: false, csrfProtection: false,
cacheImages: false,
defaultPermissions: Permission.REQUEST, defaultPermissions: Permission.REQUEST,
hideAvailable: false, hideAvailable: false,
localLogin: true, localLogin: true,
@ -349,6 +352,7 @@ class Settings {
region: this.data.main.region, region: this.data.main.region,
originalLanguage: this.data.main.originalLanguage, originalLanguage: this.data.main.originalLanguage,
partialRequestsEnabled: this.data.main.partialRequestsEnabled, partialRequestsEnabled: this.data.main.partialRequestsEnabled,
cacheImages: this.data.main.cacheImages,
}; };
} }

@ -21,6 +21,7 @@ import { useUser, Permission } from '../../hooks/useUser';
import useSettings from '../../hooks/useSettings'; import useSettings from '../../hooks/useSettings';
import Link from 'next/link'; import Link from 'next/link';
import { uniq } from 'lodash'; import { uniq } from 'lodash';
import CachedImage from '../Common/CachedImage';
const messages = defineMessages({ const messages = defineMessages({
overviewunavailable: 'Overview unavailable.', overviewunavailable: 'Overview unavailable.',
@ -203,9 +204,26 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
className="media-page" className="media-page"
style={{ style={{
height: 493, height: 493,
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
}} }}
> >
{data.backdropPath && (
<div className="media-page-bg-image">
<CachedImage
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
layout="fill"
objectFit="cover"
priority
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
}}
/>
</div>
)}
<PageTitle title={data.name} /> <PageTitle title={data.name} />
<Transition <Transition
enter="opacity-0 transition duration-300" enter="opacity-0 transition duration-300"
@ -268,11 +286,20 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
</Modal> </Modal>
</Transition> </Transition>
<div className="media-header"> <div className="media-header">
<img <div className="media-poster">
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`} <CachedImage
alt="" src={
className="media-poster" data.posterPath
/> ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
layout="responsive"
width={600}
height={900}
priority
/>
</div>
<div className="media-title"> <div className="media-title">
<div className="media-status"> <div className="media-status">
<StatusBadge <StatusBadge

@ -0,0 +1,18 @@
import Image, { ImageProps } from 'next/image';
import React from 'react';
import useSettings from '../../../hooks/useSettings';
/**
* The CachedImage component should be used wherever
* 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: React.FC<ImageProps> = (props) => {
const { currentSettings } = useSettings();
return <Image unoptimized={!currentSettings.cacheImages} {...props} />;
};
export default CachedImage;

@ -4,13 +4,13 @@ import React, {
HTMLAttributes, HTMLAttributes,
ForwardRefRenderFunction, ForwardRefRenderFunction,
} from 'react'; } from 'react';
import Image from 'next/image'; import CachedImage from '../CachedImage';
interface ImageFaderProps extends HTMLAttributes<HTMLDivElement> { interface ImageFaderProps extends HTMLAttributes<HTMLDivElement> {
backgroundImages: string[]; backgroundImages: string[];
rotationSpeed?: number; rotationSpeed?: number;
isDarker?: boolean; isDarker?: boolean;
useImage?: boolean; forceOptimize?: boolean;
} }
const DEFAULT_ROTATION_SPEED = 6000; const DEFAULT_ROTATION_SPEED = 6000;
@ -20,7 +20,7 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
backgroundImages, backgroundImages,
rotationSpeed = DEFAULT_ROTATION_SPEED, rotationSpeed = DEFAULT_ROTATION_SPEED,
isDarker, isDarker,
useImage, forceOptimize,
...props ...props
}, },
ref ref
@ -46,6 +46,14 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)'; 'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)';
} }
let overrides = {};
if (forceOptimize) {
overrides = {
unoptimized: false,
};
}
return ( return (
<div ref={ref}> <div ref={ref}>
{backgroundImages.map((imageUrl, i) => ( {backgroundImages.map((imageUrl, i) => (
@ -54,29 +62,20 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
className={`absolute inset-0 bg-cover bg-center transition-opacity duration-300 ease-in ${ className={`absolute inset-0 bg-cover bg-center transition-opacity duration-300 ease-in ${
i === activeIndex ? 'opacity-100' : 'opacity-0' i === activeIndex ? 'opacity-100' : 'opacity-0'
}`} }`}
style={{
backgroundImage: !useImage
? `${gradient}, url(${imageUrl})`
: undefined,
}}
{...props} {...props}
> >
{useImage && ( <CachedImage
<> className="absolute inset-0 w-full h-full"
<Image alt=""
className="absolute inset-0 w-full h-full" src={imageUrl}
alt="" layout="fill"
src={imageUrl} objectFit="cover"
layout="fill" {...overrides}
objectFit="cover" />
quality={100} <div
/> className="absolute inset-0"
<div style={{ backgroundImage: gradient }}
className="absolute inset-0" />
style={{ backgroundImage: gradient }}
/>
</>
)}
</div> </div>
))} ))}
</div> </div>

@ -40,7 +40,7 @@ const MovieGenreList: React.FC = () => {
<li key={`genre-${genre.id}-${index}`}> <li key={`genre-${genre.id}-${index}`}>
<GenreCard <GenreCard
name={genre.name} name={genre.name}
image={`https://www.themoviedb.org/t/p/w1280_filter(duotone,${ image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0] genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`} })${genre.backdrops[4]}`}
url={`/discover/movies/genre/${genre.id}`} url={`/discover/movies/genre/${genre.id}`}

@ -54,7 +54,7 @@ const MovieGenreSlider: React.FC = () => {
<GenreCard <GenreCard
key={`genre-${genre.id}-${index}`} key={`genre-${genre.id}-${index}`}
name={genre.name} name={genre.name}
image={`https://www.themoviedb.org/t/p/w1280_filter(duotone,${ image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0] genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`} })${genre.backdrops[4]}`}
url={`/discover/movies/genre/${genre.id}`} url={`/discover/movies/genre/${genre.id}`}

@ -40,7 +40,7 @@ const TvGenreList: React.FC = () => {
<li key={`genre-${genre.id}-${index}`}> <li key={`genre-${genre.id}-${index}`}>
<GenreCard <GenreCard
name={genre.name} name={genre.name}
image={`https://www.themoviedb.org/t/p/w1280_filter(duotone,${ image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0] genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`} })${genre.backdrops[4]}`}
url={`/discover/tv/genre/${genre.id}`} url={`/discover/tv/genre/${genre.id}`}

@ -54,7 +54,7 @@ const TvGenreSlider: React.FC = () => {
<GenreCard <GenreCard
key={`genre-tv-${genre.id}-${index}`} key={`genre-tv-${genre.id}-${index}`}
name={genre.name} name={genre.name}
image={`https://www.themoviedb.org/t/p/w1280_filter(duotone,${ image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0] genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`} })${genre.backdrops[4]}`}
url={`/discover/tv/genre/${genre.id}`} url={`/discover/tv/genre/${genre.id}`}

@ -1,6 +1,7 @@
import Link from 'next/link'; import Link from 'next/link';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { withProperties } from '../../utils/typeHelpers'; import { withProperties } from '../../utils/typeHelpers';
import CachedImage from '../Common/CachedImage';
interface GenreCardProps { interface GenreCardProps {
name: string; name: string;
@ -27,9 +28,6 @@ const GenreCard: React.FC<GenreCardProps> = ({
? 'bg-gray-700 scale-105 ring-gray-500 bg-opacity-100' ? 'bg-gray-700 scale-105 ring-gray-500 bg-opacity-100'
: 'bg-gray-800 scale-100 ring-gray-700 bg-opacity-80' : 'bg-gray-800 scale-100 ring-gray-700 bg-opacity-80'
} rounded-xl bg-cover bg-center overflow-hidden`} } rounded-xl bg-cover bg-center overflow-hidden`}
style={{
backgroundImage: `url("${image}")`,
}}
onMouseEnter={() => { onMouseEnter={() => {
setHovered(true); setHovered(true);
}} }}
@ -42,6 +40,7 @@ const GenreCard: React.FC<GenreCardProps> = ({
role="link" role="link"
tabIndex={0} tabIndex={0}
> >
<CachedImage src={image} alt="" layout="fill" objectFit="cover" />
<div <div
className={`absolute z-10 inset-0 w-full h-full transition duration-300 bg-gray-800 ${ className={`absolute z-10 inset-0 w-full h-full transition duration-300 bg-gray-800 ${
isHovered ? 'bg-opacity-10' : 'bg-opacity-30' isHovered ? 'bg-opacity-10' : 'bg-opacity-30'

@ -63,7 +63,7 @@ const Login: React.FC = () => {
<div className="relative flex flex-col min-h-screen bg-gray-900 py-14"> <div className="relative flex flex-col min-h-screen bg-gray-900 py-14">
<PageTitle title={intl.formatMessage(messages.signin)} /> <PageTitle title={intl.formatMessage(messages.signin)} />
<ImageFader <ImageFader
useImage forceOptimize
backgroundImages={[ backgroundImages={[
'/images/rotate1.jpg', '/images/rotate1.jpg',
'/images/rotate2.jpg', '/images/rotate2.jpg',

@ -31,6 +31,7 @@ import DownloadBlock from '../DownloadBlock';
import PageTitle from '../Common/PageTitle'; import PageTitle from '../Common/PageTitle';
import useSettings from '../../hooks/useSettings'; import useSettings from '../../hooks/useSettings';
import PlayButton, { PlayButtonLink } from '../Common/PlayButton'; import PlayButton, { PlayButtonLink } from '../Common/PlayButton';
import CachedImage from '../Common/CachedImage';
const messages = defineMessages({ const messages = defineMessages({
releasedate: 'Release Date', releasedate: 'Release Date',
@ -203,9 +204,26 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
className="media-page" className="media-page"
style={{ style={{
height: 493, height: 493,
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
}} }}
> >
{data.backdropPath && (
<div className="media-page-bg-image">
<CachedImage
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
layout="fill"
objectFit="cover"
priority
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
}}
/>
</div>
)}
<PageTitle title={data.title} /> <PageTitle title={data.title} />
<SlideOver <SlideOver
show={showManager} show={showManager}
@ -380,15 +398,20 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
)} )}
</SlideOver> </SlideOver>
<div className="media-header"> <div className="media-header">
<img <div className="media-poster">
src={ <CachedImage
data.posterPath src={
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}` data.posterPath
: '/images/overseerr_poster_not_found.png' ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
} : '/images/overseerr_poster_not_found.png'
alt="" }
className="media-poster" alt=""
/> layout="responsive"
width={600}
height={900}
priority
/>
</div>
<div className="media-title"> <div className="media-title">
<div className="media-status"> <div className="media-status">
<StatusBadge <StatusBadge
@ -519,13 +542,23 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<div className="mb-6"> <div className="mb-6">
<Link href={`/collection/${data.collection.id}`}> <Link href={`/collection/${data.collection.id}`}>
<a> <a>
<div <div className="relative z-0 overflow-hidden transition duration-300 scale-100 bg-gray-800 bg-center bg-cover rounded-lg shadow-md cursor-pointer transform-gpu group hover:scale-105 ring-1 ring-gray-700 hover:ring-gray-500">
className="relative z-0 transition duration-300 scale-100 bg-gray-800 bg-center bg-cover rounded-lg shadow-md cursor-pointer transform-gpu group hover:scale-105" <div className="absolute inset-0 z-0">
style={{ <CachedImage
backgroundImage: `linear-gradient(180deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 0.80) 100%), url(//image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath})`, src={`https://image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath}`}
}} alt=""
> layout="fill"
<div className="flex items-center justify-between p-4 text-gray-200 transition duration-300 h-14 group-hover:text-white"> objectFit="cover"
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 0.80) 100%)',
}}
/>
</div>
<div className="relative z-10 flex items-center justify-between p-4 text-gray-200 transition duration-300 h-14 group-hover:text-white">
<div>{data.collection.name}</div> <div>{data.collection.name}</div>
<Button buttonSize="sm"> <Button buttonSize="sm">
{intl.formatMessage(messages.view)} {intl.formatMessage(messages.view)}

@ -1,5 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import React, { useState } from 'react'; import React, { useState } from 'react';
import CachedImage from '../Common/CachedImage';
interface PersonCardProps { interface PersonCardProps {
personId: number; personId: number;
@ -47,11 +48,14 @@ const PersonCard: React.FC<PersonCardProps> = ({
<div className="absolute inset-0 flex flex-col items-center w-full h-full p-2"> <div className="absolute inset-0 flex flex-col items-center w-full h-full p-2">
<div className="relative flex justify-center w-full mt-2 mb-4 h-1/2"> <div className="relative flex justify-center w-full mt-2 mb-4 h-1/2">
{profilePath ? ( {profilePath ? (
<img <div className="relative w-3/4 h-full overflow-hidden rounded-full ring-1 ring-gray-700">
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`} <CachedImage
className="object-cover w-3/4 h-full bg-center bg-cover rounded-full ring-1 ring-gray-700" src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
alt="" alt=""
/> layout="fill"
objectFit="cover"
/>
</div>
) : ( ) : (
<svg <svg
className="h-full" className="h-full"

@ -13,6 +13,7 @@ import ImageFader from '../Common/ImageFader';
import Ellipsis from '../../assets/ellipsis.svg'; import Ellipsis from '../../assets/ellipsis.svg';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
import PageTitle from '../Common/PageTitle'; import PageTitle from '../Common/PageTitle';
import CachedImage from '../Common/CachedImage';
const messages = defineMessages({ const messages = defineMessages({
appearsin: 'Appearances', appearsin: 'Appearances',
@ -172,7 +173,7 @@ const PersonDetails: React.FC = () => {
.filter((media) => media.backdropPath) .filter((media) => media.backdropPath)
.map( .map(
(media) => (media) =>
`//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}` `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}`
) )
.slice(0, 6)} .slice(0, 6)}
/> />
@ -180,12 +181,14 @@ const PersonDetails: React.FC = () => {
)} )}
<div className="relative z-10 flex flex-col items-center mt-4 mb-8 md:flex-row md:items-start"> <div className="relative z-10 flex flex-col items-center mt-4 mb-8 md:flex-row md:items-start">
{data.profilePath && ( {data.profilePath && (
<div <div className="relative flex-shrink-0 mb-6 mr-0 overflow-hidden rounded-full w-36 h-36 md:w-44 md:h-44 md:mb-0 md:mr-6 ring-1 ring-gray-700">
style={{ <CachedImage
backgroundImage: `url(https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath})`, src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath}`}
}} alt=""
className="flex-shrink-0 mb-6 mr-0 bg-center bg-cover rounded-full w-36 h-36 md:w-44 md:h-44 md:mb-0 md:mr-6" layout="fill"
/> objectFit="cover"
/>
</div>
)} )}
<div className="text-center text-gray-300 md:text-left"> <div className="text-center text-gray-300 md:text-left">
<h1 className="mb-4 text-3xl text-white md:text-4xl">{data.name}</h1> <h1 className="mb-4 text-3xl text-white md:text-4xl">{data.name}</h1>

@ -18,6 +18,7 @@ import Link from 'next/link';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import globalMessages from '../../i18n/globalMessages'; import globalMessages from '../../i18n/globalMessages';
import StatusBadge from '../StatusBadge'; import StatusBadge from '../StatusBadge';
import CachedImage from '../Common/CachedImage';
const messages = defineMessages({ const messages = defineMessages({
status: 'Status', status: 'Status',
@ -97,13 +98,25 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
} }
return ( return (
<div <div className="relative flex p-4 overflow-hidden text-gray-400 bg-gray-800 bg-center bg-cover shadow rounded-xl w-72 sm:w-96 ring-1 ring-gray-700">
className="relative flex p-4 text-gray-400 bg-gray-700 bg-center bg-cover shadow rounded-xl w-72 sm:w-96 ring-1 ring-gray-700" {title.backdropPath && (
style={{ <div className="absolute inset-0 z-0">
backgroundImage: `linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 75%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath})`, <CachedImage
}} alt=""
> src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
<div className="flex flex-col flex-1 min-w-0 pr-4"> layout="fill"
objectFit="cover"
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 75%)',
}}
/>
</div>
)}
<div className="relative z-10 flex flex-col flex-1 min-w-0 pr-4">
<Link <Link
href={ href={
request.type === 'movie' request.type === 'movie'
@ -243,15 +256,17 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
: `/tv/${requestData.media.tmdbId}` : `/tv/${requestData.media.tmdbId}`
} }
> >
<a className="flex-shrink-0 w-20 sm:w-28"> <a className="flex-shrink-0 w-20 overflow-hidden transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer sm:w-28 transform-gpu hover:scale-105 hover:shadow-md">
<img <CachedImage
src={ src={
title.posterPath title.posterPath
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png' : '/images/overseerr_poster_not_found.png'
} }
alt="" alt=""
className="w-20 transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer sm:w-28 transform-gpu hover:scale-105 hover:shadow-md" layout="responsive"
width={600}
height={900}
/> />
</a> </a>
</Link> </Link>

@ -20,6 +20,7 @@ import Link from 'next/link';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import RequestModal from '../../RequestModal'; import RequestModal from '../../RequestModal';
import ConfirmButton from '../../Common/ConfirmButton'; import ConfirmButton from '../../Common/ConfirmButton';
import CachedImage from '../../Common/CachedImage';
const messages = defineMessages({ const messages = defineMessages({
seasons: '{seasonCount, plural, one {Season} other {Seasons}}', seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
@ -133,14 +134,23 @@ const RequestItem: React.FC<RequestItemProps> = ({
}} }}
/> />
<div className="relative flex flex-col justify-between w-full py-4 overflow-hidden text-gray-400 bg-gray-800 shadow-md ring-1 ring-gray-700 rounded-xl xl:h-32 xl:flex-row"> <div className="relative flex flex-col justify-between w-full py-4 overflow-hidden text-gray-400 bg-gray-800 shadow-md ring-1 ring-gray-700 rounded-xl xl:h-32 xl:flex-row">
<div {title.backdropPath && (
className="absolute inset-0 z-0 w-full bg-center bg-cover xl:w-2/3" <div className="absolute inset-0 z-0 w-full bg-center bg-cover xl:w-2/3">
style={{ <CachedImage
backgroundImage: title.backdropPath src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
? `linear-gradient(90deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath})` alt=""
: undefined, layout="fill"
}} objectFit="cover"
/> />
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(90deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 1) 100%)',
}}
/>
</div>
)}
<div className="relative flex flex-col justify-between w-full overflow-hidden sm:flex-row"> <div className="relative flex flex-col justify-between w-full overflow-hidden sm:flex-row">
<div className="relative z-10 flex items-center w-full pl-4 pr-4 overflow-hidden xl:w-7/12 2xl:w-2/3 sm:pr-0"> <div className="relative z-10 flex items-center w-full pl-4 pr-4 overflow-hidden xl:w-7/12 2xl:w-2/3 sm:pr-0">
<Link <Link
@ -150,15 +160,18 @@ const RequestItem: React.FC<RequestItemProps> = ({
: `/tv/${requestData.media.tmdbId}` : `/tv/${requestData.media.tmdbId}`
} }
> >
<a className="flex-shrink-0 w-12 h-auto overflow-hidden transition duration-300 scale-100 rounded-md sm:w-14 transform-gpu hover:scale-105"> <a className="relative flex-shrink-0 w-12 h-auto overflow-hidden transition duration-300 scale-100 rounded-md sm:w-14 transform-gpu hover:scale-105">
<img <CachedImage
src={ src={
title.posterPath title.posterPath
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png' : '/images/overseerr_poster_not_found.png'
} }
alt="" alt=""
className="object-cover" layout="responsive"
width={600}
height={900}
objectFit="cover"
/> />
</a> </a>
</Link> </Link>

@ -39,6 +39,9 @@ const messages = defineMessages({
'Sets external API access to read-only (requires HTTPS, and Overseerr must be reloaded for changes to take effect)', 'Sets external API access to read-only (requires HTTPS, and Overseerr must be reloaded for changes to take effect)',
csrfProtectionHoverTip: csrfProtectionHoverTip:
'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: 'Cache & Optimize Images Locally',
cacheImagesTip:
'Enabling this option will cause all images to be optimized and stored locally. This uses a significant amount of disk space.',
trustProxy: 'Enable Proxy Support', trustProxy: 'Enable Proxy Support',
trustProxyTip: trustProxyTip:
'Allows Overseerr to correctly register client IP addresses behind a proxy (Overseerr must be reloaded for changes to take effect)', 'Allows Overseerr to correctly register client IP addresses behind a proxy (Overseerr must be reloaded for changes to take effect)',
@ -144,6 +147,7 @@ const SettingsMain: React.FC = () => {
originalLanguage: data?.originalLanguage, originalLanguage: data?.originalLanguage,
partialRequestsEnabled: data?.partialRequestsEnabled, partialRequestsEnabled: data?.partialRequestsEnabled,
trustProxy: data?.trustProxy, trustProxy: data?.trustProxy,
cacheImages: data?.cacheImages,
}} }}
enableReinitialize enableReinitialize
validationSchema={MainSettingsSchema} validationSchema={MainSettingsSchema}
@ -158,6 +162,7 @@ const SettingsMain: React.FC = () => {
originalLanguage: values.originalLanguage, originalLanguage: values.originalLanguage,
partialRequestsEnabled: values.partialRequestsEnabled, partialRequestsEnabled: values.partialRequestsEnabled,
trustProxy: values.trustProxy, trustProxy: values.trustProxy,
cacheImages: values.cacheImages,
}); });
addToast(intl.formatMessage(messages.toastSettingsSuccess), { addToast(intl.formatMessage(messages.toastSettingsSuccess), {
@ -299,6 +304,26 @@ const SettingsMain: React.FC = () => {
/> />
</div> </div>
</div> </div>
<div className="form-row">
<label htmlFor="csrfProtection" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.cacheImages)}
</span>
<Badge badgeType="warning">
{intl.formatMessage(globalMessages.experimental)}
</Badge>
<span className="label-tip">
{intl.formatMessage(messages.cacheImagesTip)}
</span>
</label>
<div className="form-input">
<Field
type="checkbox"
id="cacheImages"
name="cacheImages"
/>
</div>
</div>
<div className="form-row"> <div className="form-row">
<label htmlFor="region" className="text-label"> <label htmlFor="region" className="text-label">
<span>{intl.formatMessage(messages.region)}</span> <span>{intl.formatMessage(messages.region)}</span>

@ -11,6 +11,7 @@ import { useIsTouch } from '../../hooks/useIsTouch';
import globalMessages from '../../i18n/globalMessages'; import globalMessages from '../../i18n/globalMessages';
import Spinner from '../../assets/spinner.svg'; import Spinner from '../../assets/spinner.svg';
import { useUser, Permission } from '../../hooks/useUser'; import { useUser, Permission } from '../../hooks/useUser';
import CachedImage from '../Common/CachedImage';
const messages = defineMessages({ const messages = defineMessages({
movie: 'Movie', movie: 'Movie',
@ -81,16 +82,13 @@ const TitleCard: React.FC<TitleCardProps> = ({
onCancel={closeModal} onCancel={closeModal}
/> />
<div <div
className={`transition duration-300 transform-gpu scale-100 outline-none cursor-default relative bg-gray-800 bg-cover rounded-xl ring-1 ${ className={`transition duration-300 transform-gpu scale-100 outline-none cursor-default relative bg-gray-800 bg-cover rounded-xl ring-1 overflow-hidden ${
showDetail showDetail
? 'scale-105 shadow-lg ring-gray-500' ? 'scale-105 shadow-lg ring-gray-500'
: 'shadow ring-gray-700' : 'shadow ring-gray-700'
}`} }`}
style={{ style={{
paddingBottom: '150%', paddingBottom: '150%',
backgroundImage: image
? `url(//image.tmdb.org/t/p/w300_and_h450_face${image})`
: `url('/images/overseerr_poster_not_found_logo_top.png')`,
}} }}
onMouseEnter={() => { onMouseEnter={() => {
if (!isTouch) { if (!isTouch) {
@ -108,6 +106,17 @@ const TitleCard: React.FC<TitleCardProps> = ({
tabIndex={0} tabIndex={0}
> >
<div className="absolute inset-0 w-full h-full overflow-hidden"> <div className="absolute inset-0 w-full h-full overflow-hidden">
<CachedImage
className="absolute inset-0 w-full h-full"
alt=""
src={
image
? `https://image.tmdb.org/t/p/w300_and_h450_face${image}`
: `/images/overseerr_poster_not_found_logo_top.png`
}
layout="fill"
objectFit="cover"
/>
<div className="absolute left-0 right-0 flex items-center justify-between p-2"> <div className="absolute left-0 right-0 flex items-center justify-between p-2">
<div <div
className={`rounded-full z-40 pointer-events-none shadow ${ className={`rounded-full z-40 pointer-events-none shadow ${

@ -34,6 +34,7 @@ import DownloadBlock from '../DownloadBlock';
import PageTitle from '../Common/PageTitle'; import PageTitle from '../Common/PageTitle';
import useSettings from '../../hooks/useSettings'; import useSettings from '../../hooks/useSettings';
import PlayButton, { PlayButtonLink } from '../Common/PlayButton'; import PlayButton, { PlayButtonLink } from '../Common/PlayButton';
import CachedImage from '../Common/CachedImage';
const messages = defineMessages({ const messages = defineMessages({
firstAirDate: 'First Air Date', firstAirDate: 'First Air Date',
@ -228,9 +229,26 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
className="media-page" className="media-page"
style={{ style={{
height: 493, height: 493,
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
}} }}
> >
{data.backdropPath && (
<div className="media-page-bg-image">
<CachedImage
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
layout="fill"
objectFit="cover"
priority
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
}}
/>
</div>
)}
<PageTitle title={data.name} /> <PageTitle title={data.name} />
<RequestModal <RequestModal
tmdbId={data.id} tmdbId={data.id}
@ -418,15 +436,20 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
)} )}
</SlideOver> </SlideOver>
<div className="media-header"> <div className="media-header">
<img <div className="media-poster">
src={ <CachedImage
data.posterPath src={
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}` data.posterPath
: '/images/overseerr_poster_not_found.png' ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
} : '/images/overseerr_poster_not_found.png'
alt="" }
className="media-poster" alt=""
/> layout="responsive"
width={600}
height={900}
priority
/>
</div>
<div className="media-title"> <div className="media-title">
<div className="media-status"> <div className="media-status">
<StatusBadge <StatusBadge

@ -48,7 +48,7 @@ const ProfileHeader: React.FC<ProfileHeaderProps> = ({
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className="relative"> <div className="relative">
<img <img
className="w-24 h-24 bg-gray-600 rounded-full" className="w-24 h-24 bg-gray-600 rounded-full ring-1 ring-gray-700"
src={user.avatar} src={user.avatar}
alt="" alt=""
/> />

@ -69,7 +69,7 @@ const UserProfile: React.FC = () => {
.filter((media) => media.backdropPath) .filter((media) => media.backdropPath)
.map( .map(
(media) => (media) =>
`//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}` `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}`
) )
.slice(0, 6)} .slice(0, 6)}
/> />

@ -16,6 +16,7 @@ const defaultSettings = {
region: '', region: '',
originalLanguage: '', originalLanguage: '',
partialRequestsEnabled: true, partialRequestsEnabled: true,
cacheImages: false,
}; };
export const SettingsContext = React.createContext<SettingsContextProps>({ export const SettingsContext = React.createContext<SettingsContextProps>({

@ -536,6 +536,8 @@
"components.Settings.apikey": "API Key", "components.Settings.apikey": "API Key",
"components.Settings.applicationTitle": "Application Title", "components.Settings.applicationTitle": "Application Title",
"components.Settings.applicationurl": "Application URL", "components.Settings.applicationurl": "Application URL",
"components.Settings.cacheImages": "Cache & Optimize Images Locally",
"components.Settings.cacheImagesTip": "Enabling this option will cause all images to be optimized and stored locally. This uses 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",

@ -149,6 +149,7 @@ CoreApp.getInitialProps = async (initialProps) => {
region: '', region: '',
originalLanguage: '', originalLanguage: '',
partialRequestsEnabled: true, partialRequestsEnabled: true,
cacheImages: false,
}; };
let locale = 'en'; let locale = 'en';

@ -42,7 +42,12 @@ a.slider-title {
} }
.media-page { .media-page {
@apply px-4 pt-16 -mx-4 -mt-16 bg-center bg-cover; @apply relative px-4 pt-16 -mx-4 -mt-16 bg-center bg-cover;
}
.media-page-bg-image {
@apply absolute inset-0 w-full h-full;
z-index: -10;
} }
.media-header { .media-header {
@ -50,7 +55,7 @@ a.slider-title {
} }
.media-poster { .media-poster {
@apply w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-44 xl:w-52 xl:mr-4; @apply w-32 overflow-hidden rounded shadow md:rounded-lg md:shadow-2xl md:w-44 xl:w-52 xl:mr-4;
} }
.media-status { .media-status {

Loading…
Cancel
Save