* fix: rewrite avatarproxy and CachedImage Avatar proxy was allowing every request to be proxied, no matter the original ressource's origin or filetype. This PR fixes it be allowing only relevant resources to be cached, i.e. Jellyfin/Emby images and TMDB images. fix #1012, #1013 * fix: resolve CodeQL error * fix: resolve CodeQL error * fix: resolve review comments * fix: resolve review comment * fix: resolve CodeQL error * fix: update imageproxy path
551 lines
16 KiB
TypeScript
551 lines
16 KiB
TypeScript
import CachedImage from '@app/components/Common/CachedImage';
|
|
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
|
|
import Tooltip from '@app/components/Common/Tooltip';
|
|
import RegionSelector from '@app/components/RegionSelector';
|
|
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
|
import useSettings from '@app/hooks/useSettings';
|
|
import defineMessages from '@app/utils/defineMessages';
|
|
import { ArrowDownIcon, ArrowUpIcon } from '@heroicons/react/20/solid';
|
|
import { CheckCircleIcon } from '@heroicons/react/24/solid';
|
|
import type {
|
|
TmdbCompanySearchResponse,
|
|
TmdbGenre,
|
|
TmdbKeywordSearchResponse,
|
|
} from '@server/api/themoviedb/interfaces';
|
|
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
|
import type {
|
|
Keyword,
|
|
ProductionCompany,
|
|
WatchProviderDetails,
|
|
} from '@server/models/common';
|
|
import { orderBy } from 'lodash';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { useIntl } from 'react-intl';
|
|
import type { MultiValue, SingleValue } from 'react-select';
|
|
import AsyncSelect from 'react-select/async';
|
|
import useSWR from 'swr';
|
|
|
|
const messages = defineMessages('components.Selector', {
|
|
searchKeywords: 'Search keywords…',
|
|
searchGenres: 'Select genres…',
|
|
searchStudios: 'Search studios…',
|
|
starttyping: 'Starting typing to search.',
|
|
nooptions: 'No results.',
|
|
showmore: 'Show More',
|
|
showless: 'Show Less',
|
|
searchStatus: 'Select status...',
|
|
returningSeries: 'Returning Series',
|
|
planned: 'Planned',
|
|
inProduction: 'In Production',
|
|
ended: 'Ended',
|
|
canceled: 'Canceled',
|
|
pilot: 'Pilot',
|
|
});
|
|
|
|
type SingleVal = {
|
|
label: string;
|
|
value: number;
|
|
};
|
|
|
|
type BaseSelectorMultiProps = {
|
|
defaultValue?: string;
|
|
isMulti: true;
|
|
onChange: (value: MultiValue<SingleVal> | null) => void;
|
|
};
|
|
|
|
type BaseSelectorSingleProps = {
|
|
defaultValue?: string;
|
|
isMulti?: false;
|
|
onChange: (value: SingleValue<SingleVal> | null) => void;
|
|
};
|
|
|
|
export const CompanySelector = ({
|
|
defaultValue,
|
|
isMulti,
|
|
onChange,
|
|
}: BaseSelectorSingleProps | BaseSelectorMultiProps) => {
|
|
const intl = useIntl();
|
|
const [defaultDataValue, setDefaultDataValue] = useState<
|
|
{ label: string; value: number }[] | null
|
|
>(null);
|
|
|
|
useEffect(() => {
|
|
const loadDefaultCompany = async (): Promise<void> => {
|
|
if (!defaultValue) {
|
|
return;
|
|
}
|
|
|
|
const res = await fetch(`/api/v1/studio/${defaultValue}`);
|
|
if (!res.ok) throw new Error();
|
|
const studio: ProductionCompany = await res.json();
|
|
|
|
setDefaultDataValue([
|
|
{
|
|
label: studio.name ?? '',
|
|
value: studio.id ?? 0,
|
|
},
|
|
]);
|
|
};
|
|
|
|
loadDefaultCompany();
|
|
}, [defaultValue]);
|
|
|
|
const loadCompanyOptions = async (inputValue: string) => {
|
|
if (inputValue === '') {
|
|
return [];
|
|
}
|
|
|
|
const res = await fetch(
|
|
`/api/v1/search/company?query=${encodeURIExtraParams(inputValue)}`
|
|
);
|
|
if (!res.ok) {
|
|
throw new Error('Network response was not ok');
|
|
}
|
|
const results: TmdbCompanySearchResponse = await res.json();
|
|
|
|
return results.results.map((result) => ({
|
|
label: result.name,
|
|
value: result.id,
|
|
}));
|
|
};
|
|
|
|
return (
|
|
<AsyncSelect
|
|
key={`company-selector-${defaultDataValue}`}
|
|
className="react-select-container"
|
|
classNamePrefix="react-select"
|
|
isMulti={isMulti}
|
|
defaultValue={defaultDataValue}
|
|
defaultOptions
|
|
cacheOptions
|
|
isClearable
|
|
noOptionsMessage={({ inputValue }) =>
|
|
inputValue === ''
|
|
? intl.formatMessage(messages.starttyping)
|
|
: intl.formatMessage(messages.nooptions)
|
|
}
|
|
loadOptions={loadCompanyOptions}
|
|
placeholder={intl.formatMessage(messages.searchStudios)}
|
|
onChange={(value) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
onChange(value as any);
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
type GenreSelectorProps = (BaseSelectorMultiProps | BaseSelectorSingleProps) & {
|
|
type: 'movie' | 'tv';
|
|
};
|
|
|
|
export const GenreSelector = ({
|
|
isMulti,
|
|
defaultValue,
|
|
onChange,
|
|
type,
|
|
}: GenreSelectorProps) => {
|
|
const intl = useIntl();
|
|
const [defaultDataValue, setDefaultDataValue] = useState<
|
|
{ label: string; value: number }[] | null
|
|
>(null);
|
|
|
|
useEffect(() => {
|
|
const loadDefaultGenre = async (): Promise<void> => {
|
|
if (!defaultValue) {
|
|
return;
|
|
}
|
|
|
|
const genres = defaultValue.split(',');
|
|
|
|
const res = await fetch(`/api/v1/genres/${type}`);
|
|
if (!res.ok) {
|
|
throw new Error('Network response was not ok');
|
|
}
|
|
const response: TmdbGenre[] = await res.json();
|
|
|
|
const genreData = genres
|
|
.filter((genre) => response.find((gd) => gd.id === Number(genre)))
|
|
.map((g) => response.find((gd) => gd.id === Number(g)))
|
|
.map((g) => ({
|
|
label: g?.name ?? '',
|
|
value: g?.id ?? 0,
|
|
}));
|
|
|
|
setDefaultDataValue(genreData);
|
|
};
|
|
|
|
loadDefaultGenre();
|
|
}, [defaultValue, type]);
|
|
|
|
const loadGenreOptions = async (inputValue: string) => {
|
|
const res = await fetch(`/api/v1/discover/genreslider/${type}`);
|
|
if (!res.ok) throw new Error();
|
|
const results: GenreSliderItem[] = await res.json();
|
|
|
|
return results
|
|
.map((result) => ({
|
|
label: result.name,
|
|
value: result.id,
|
|
}))
|
|
.filter(({ label }) =>
|
|
label.toLowerCase().includes(inputValue.toLowerCase())
|
|
);
|
|
};
|
|
|
|
return (
|
|
<AsyncSelect
|
|
key={`genre-select-${defaultDataValue}`}
|
|
className="react-select-container"
|
|
classNamePrefix="react-select"
|
|
defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]}
|
|
defaultOptions
|
|
cacheOptions
|
|
isMulti={isMulti}
|
|
loadOptions={loadGenreOptions}
|
|
placeholder={intl.formatMessage(messages.searchGenres)}
|
|
onChange={(value) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
onChange(value as any);
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const StatusSelector = ({
|
|
isMulti,
|
|
defaultValue,
|
|
onChange,
|
|
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
|
|
const intl = useIntl();
|
|
const [defaultDataValue, setDefaultDataValue] = useState<
|
|
{ label: string; value: number }[] | null
|
|
>(null);
|
|
|
|
const options = useMemo(
|
|
() => [
|
|
{ name: intl.formatMessage(messages.returningSeries), id: 0 },
|
|
{ name: intl.formatMessage(messages.planned), id: 1 },
|
|
{ name: intl.formatMessage(messages.inProduction), id: 2 },
|
|
{ name: intl.formatMessage(messages.ended), id: 3 },
|
|
{ name: intl.formatMessage(messages.canceled), id: 4 },
|
|
{ name: intl.formatMessage(messages.pilot), id: 5 },
|
|
],
|
|
[intl]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const loadDefaultStatus = async (): Promise<void> => {
|
|
if (!defaultValue) {
|
|
return;
|
|
}
|
|
const statuses = defaultValue.split('|');
|
|
|
|
const statusData = options
|
|
.filter((opt) => statuses.find((s) => Number(s) === opt.id))
|
|
.map((o) => ({
|
|
label: o.name,
|
|
value: o.id,
|
|
}));
|
|
|
|
setDefaultDataValue(statusData);
|
|
};
|
|
|
|
loadDefaultStatus();
|
|
}, [defaultValue, options]);
|
|
|
|
const loadStatusOptions = async () => {
|
|
return options
|
|
.map((result) => ({
|
|
label: result.name,
|
|
value: result.id,
|
|
}))
|
|
.filter(({ label }) => label.toLowerCase());
|
|
};
|
|
|
|
return (
|
|
<AsyncSelect
|
|
key={`status-select-${defaultDataValue}`}
|
|
className="react-select-container"
|
|
classNamePrefix="react-select"
|
|
defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]}
|
|
defaultOptions
|
|
isMulti={isMulti}
|
|
loadOptions={loadStatusOptions}
|
|
placeholder={intl.formatMessage(messages.searchStatus)}
|
|
onChange={(value) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
onChange(value as any);
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const KeywordSelector = ({
|
|
isMulti,
|
|
defaultValue,
|
|
onChange,
|
|
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
|
|
const intl = useIntl();
|
|
const [defaultDataValue, setDefaultDataValue] = useState<
|
|
{ label: string; value: number }[] | null
|
|
>(null);
|
|
|
|
useEffect(() => {
|
|
const loadDefaultKeywords = async (): Promise<void> => {
|
|
if (!defaultValue) {
|
|
return;
|
|
}
|
|
|
|
const keywords = await Promise.all(
|
|
defaultValue.split(',').map(async (keywordId) => {
|
|
const res = await fetch(`/api/v1/keyword/${keywordId}`);
|
|
if (!res.ok) {
|
|
throw new Error('Network response was not ok');
|
|
}
|
|
const keyword: Keyword = await res.json();
|
|
|
|
return keyword;
|
|
})
|
|
);
|
|
|
|
setDefaultDataValue(
|
|
keywords.map((keyword) => ({
|
|
label: keyword.name,
|
|
value: keyword.id,
|
|
}))
|
|
);
|
|
};
|
|
|
|
loadDefaultKeywords();
|
|
}, [defaultValue]);
|
|
|
|
const loadKeywordOptions = async (inputValue: string) => {
|
|
const res = await fetch(
|
|
`/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`
|
|
);
|
|
if (!res.ok) {
|
|
throw new Error('Network response was not ok');
|
|
}
|
|
const results: TmdbKeywordSearchResponse = await res.json();
|
|
|
|
return results.results.map((result) => ({
|
|
label: result.name,
|
|
value: result.id,
|
|
}));
|
|
};
|
|
|
|
return (
|
|
<AsyncSelect
|
|
key={`keyword-select-${defaultDataValue}`}
|
|
inputId="data"
|
|
isMulti={isMulti}
|
|
className="react-select-container"
|
|
classNamePrefix="react-select"
|
|
noOptionsMessage={({ inputValue }) =>
|
|
inputValue === ''
|
|
? intl.formatMessage(messages.starttyping)
|
|
: intl.formatMessage(messages.nooptions)
|
|
}
|
|
defaultValue={defaultDataValue}
|
|
loadOptions={loadKeywordOptions}
|
|
placeholder={intl.formatMessage(messages.searchKeywords)}
|
|
onChange={(value) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
onChange(value as any);
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
type WatchProviderSelectorProps = {
|
|
type: 'movie' | 'tv';
|
|
region?: string;
|
|
activeProviders?: number[];
|
|
onChange: (region: string, value: number[]) => void;
|
|
};
|
|
|
|
export const WatchProviderSelector = ({
|
|
type,
|
|
onChange,
|
|
region,
|
|
activeProviders,
|
|
}: WatchProviderSelectorProps) => {
|
|
const intl = useIntl();
|
|
const { currentSettings } = useSettings();
|
|
const [showMore, setShowMore] = useState(false);
|
|
const [watchRegion, setWatchRegion] = useState(
|
|
region ? region : currentSettings.region ? currentSettings.region : 'US'
|
|
);
|
|
const [activeProvider, setActiveProvider] = useState<number[]>(
|
|
activeProviders ?? []
|
|
);
|
|
const { data, isLoading } = useSWR<WatchProviderDetails[]>(
|
|
`/api/v1/watchproviders/${
|
|
type === 'movie' ? 'movies' : 'tv'
|
|
}?watchRegion=${watchRegion}`
|
|
);
|
|
|
|
useEffect(() => {
|
|
onChange(watchRegion, activeProvider);
|
|
// removed onChange as a dependency as we only need to call it when the value(s) change
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [activeProvider, watchRegion]);
|
|
|
|
const orderedData = useMemo(() => {
|
|
if (!data) {
|
|
return [];
|
|
}
|
|
|
|
return orderBy(data, ['display_priority'], ['asc']);
|
|
}, [data]);
|
|
|
|
const toggleProvider = (id: number) => {
|
|
if (activeProvider.includes(id)) {
|
|
setActiveProvider(activeProvider.filter((p) => p !== id));
|
|
} else {
|
|
setActiveProvider([...activeProvider, id]);
|
|
}
|
|
};
|
|
|
|
const initialProviders = orderedData.slice(0, 24);
|
|
const otherProviders = orderedData.slice(24);
|
|
|
|
return (
|
|
<>
|
|
<RegionSelector
|
|
value={watchRegion}
|
|
name="watchRegion"
|
|
onChange={(_name, value) => {
|
|
if (value !== watchRegion) {
|
|
setActiveProvider([]);
|
|
}
|
|
setWatchRegion(value);
|
|
}}
|
|
disableAll
|
|
watchProviders
|
|
/>
|
|
{isLoading ? (
|
|
<SmallLoadingSpinner />
|
|
) : (
|
|
<div className="grid">
|
|
<div className="provider-icons grid gap-2">
|
|
{initialProviders.map((provider) => {
|
|
const isActive = activeProvider.includes(provider.id);
|
|
return (
|
|
<Tooltip
|
|
content={provider.name}
|
|
key={`prodiver-${provider.id}`}
|
|
>
|
|
<div
|
|
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 ${
|
|
isActive
|
|
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
|
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
|
}`}
|
|
onClick={() => toggleProvider(provider.id)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
toggleProvider(provider.id);
|
|
}
|
|
}}
|
|
role="button"
|
|
tabIndex={0}
|
|
>
|
|
<CachedImage
|
|
type="tmdb"
|
|
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
|
alt=""
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'contain',
|
|
}}
|
|
fill
|
|
className="rounded-lg"
|
|
/>
|
|
{isActive && (
|
|
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
|
|
<CheckCircleIcon className="h-6 w-6" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</div>
|
|
{showMore && otherProviders.length > 0 && (
|
|
<div className="provider-icons relative top-2 grid gap-2">
|
|
{otherProviders.map((provider) => {
|
|
const isActive = activeProvider.includes(provider.id);
|
|
return (
|
|
<Tooltip
|
|
content={provider.name}
|
|
key={`prodiver-${provider.id}`}
|
|
>
|
|
<div
|
|
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
|
|
isActive
|
|
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
|
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
|
}`}
|
|
onClick={() => toggleProvider(provider.id)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
toggleProvider(provider.id);
|
|
}
|
|
}}
|
|
role="button"
|
|
tabIndex={0}
|
|
>
|
|
<CachedImage
|
|
type="tmdb"
|
|
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
|
alt=""
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover',
|
|
}}
|
|
fill
|
|
className="rounded-lg"
|
|
/>
|
|
{isActive && (
|
|
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
|
|
<CheckCircleIcon className="h-6 w-6" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{otherProviders.length > 0 && (
|
|
<button
|
|
className="relative top-4 flex items-center justify-center space-x-2 text-sm text-gray-400 transition hover:text-gray-200"
|
|
type="button"
|
|
onClick={() => setShowMore(!showMore)}
|
|
>
|
|
<div className="h-0.5 flex-1 bg-gray-600" />
|
|
{showMore ? (
|
|
<>
|
|
<ArrowUpIcon className="h-4 w-4" />
|
|
<span>{intl.formatMessage(messages.showless)}</span>
|
|
<ArrowUpIcon className="h-4 w-4" />
|
|
</>
|
|
) : (
|
|
<>
|
|
<ArrowDownIcon className="h-4 w-4" />
|
|
<span>{intl.formatMessage(messages.showmore)}</span>
|
|
<ArrowDownIcon className="h-4 w-4" />
|
|
</>
|
|
)}
|
|
<div className="h-0.5 flex-1 bg-gray-600" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|