diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 74ec5841f..20f9e1326 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -487,6 +487,51 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { } } + private filterResults(results: T[]): T[] { + const isMultiRegion = + this.discoverRegion && this.discoverRegion.includes('|'); + const targetRegions = + isMultiRegion && this.discoverRegion + ? this.discoverRegion.split('|') + : []; + const targetLanguage = + this.originalLanguage && this.originalLanguage !== 'all' + ? this.originalLanguage + : null; + + if (!isMultiRegion && !targetLanguage) { + return results; + } + + return results.filter((result) => { + const item = result as unknown as { + origin_country?: string[]; + original_language?: string; + }; + + // Filter by Language if set + if (targetLanguage && item.original_language) { + if (item.original_language !== targetLanguage) { + return false; + } + } + + // Filter by Region if multi-region is set + if (isMultiRegion) { + // Logic Rule: + // If origin_country is present (TV Shows, usually), strictly filter. + // If origin_country is missing (Movies, often), include it to avoid over-filtering. + if (item.origin_country && Array.isArray(item.origin_country)) { + return item.origin_country.some((country) => + targetRegions.includes(country) + ); + } + } + + return true; + }); + } + public getDiscoverMovies = async ({ sortBy = 'popularity.desc', page = 1, @@ -524,6 +569,8 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { .toISOString() .split('T')[0]; + const isMultiRegion = this.discoverRegion?.includes('|'); + const data = await this.get('/discover/movie', { params: { sort_by: sortBy, @@ -531,7 +578,8 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { include_adult: includeAdult, include_video: includeVideo, language, - region: this.discoverRegion || '', + region: isMultiRegion ? undefined : this.discoverRegion || '', + with_origin_country: isMultiRegion ? this.discoverRegion : undefined, with_original_language: originalLanguage && originalLanguage !== 'all' ? originalLanguage @@ -610,12 +658,15 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { .toISOString() .split('T')[0]; + const isMultiRegion = this.discoverRegion?.includes('|'); + const data = await this.get('/discover/tv', { params: { sort_by: sortBy, page, language, - region: this.discoverRegion || '', + region: isMultiRegion ? undefined : this.discoverRegion || '', + with_origin_country: isMultiRegion ? this.discoverRegion : undefined, // Set our release date values, but check if one is set and not the other, // so we can force a past date or a future date. TMDB Requires both values if one is set! 'first_air_date.gte': @@ -679,6 +730,8 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { } ); + data.results = this.filterResults(data.results); + return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`); @@ -706,6 +759,8 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { } ); + data.results = this.filterResults(data.results); + return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); @@ -729,9 +784,11 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { } ); + data.results = this.filterResults(data.results); + return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch trending movies: ${e.message}`); } }; @@ -752,9 +809,11 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { } ); + data.results = this.filterResults(data.results); + return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch trending TV shows: ${e.message}`); } }; diff --git a/src/components/RegionSelector/index.tsx b/src/components/RegionSelector/index.tsx index 989c62efe..9a5f74890 100644 --- a/src/components/RegionSelector/index.tsx +++ b/src/components/RegionSelector/index.tsx @@ -12,7 +12,8 @@ import useSWR from 'swr'; const messages = defineMessages('components.RegionSelector', { regionDefault: 'All Regions', - regionServerDefault: 'Default ({region})', + regionServerDefault: 'Default', + regionsSelected: '{count, plural, one {Region} other {Regions}} Selected', }); interface RegionSelectorProps { @@ -39,7 +40,7 @@ const RegionSelector = ({ const { data: regions } = useSWR( watchProviders ? '/api/v1/watchproviders/regions' : '/api/v1/regions' ); - const [selectedRegion, setSelectedRegion] = useState(null); + const [selectedRegionsList, setSelectedRegionsList] = useState([]); const allRegion: Region = useMemo( () => ({ @@ -49,6 +50,14 @@ const RegionSelector = ({ [] ); + const defaultRegion: Region = useMemo( + () => ({ + iso_3166_1: 'default', + english_name: 'Default', + }), + [] + ); + const sortedRegions = useMemo(() => { regions?.forEach((region) => { region.name = @@ -73,56 +82,145 @@ const RegionSelector = ({ useEffect(() => { if (regions && value) { if (value === 'all') { - setSelectedRegion(allRegion); + setSelectedRegionsList([allRegion]); + } else if (value === '' && isUserSetting) { + setSelectedRegionsList([defaultRegion]); } else { - const matchedRegion = regions.find( - (region) => region.iso_3166_1 === value + const regionCodes = value.split('|'); + const matchedRegions = regions.filter((region) => + regionCodes.includes(region.iso_3166_1) ); - setSelectedRegion(matchedRegion ?? null); + setSelectedRegionsList(matchedRegions); } + } else if (isUserSetting && value === '') { + setSelectedRegionsList([defaultRegion]); } - }, [value, regions, allRegion]); + }, [value, regions, allRegion, isUserSetting, defaultRegion]); - useEffect(() => { - if (onChange && regions) { - if (selectedRegion) { - onChange(name, selectedRegion.iso_3166_1); - } else { + const handleChange = (newSelectedRegions: Region[]) => { + // Check if we are selecting/deselecting "All" or "Default" + const isAllSelected = newSelectedRegions.find( + (r) => r.iso_3166_1 === 'all' + ); + const wasAllSelected = selectedRegionsList.find( + (r) => r.iso_3166_1 === 'all' + ); + const isDefaultSelected = newSelectedRegions.find( + (r) => r.iso_3166_1 === 'default' + ); + const wasDefaultSelected = selectedRegionsList.find( + (r) => r.iso_3166_1 === 'default' + ); + + let finalSelection = newSelectedRegions; + + if (isAllSelected && !wasAllSelected) { + // If "All" was just selected, clear everything else + finalSelection = [allRegion]; + } else if (isDefaultSelected && !wasDefaultSelected) { + // If "Default" was just selected, clear everything else + finalSelection = [defaultRegion]; + } else if ( + (wasAllSelected && newSelectedRegions.length > 1) || + (wasDefaultSelected && newSelectedRegions.length > 1) + ) { + // If "All" or "Default" was selected and we selected something else, remove "All"/"Default" + finalSelection = newSelectedRegions.filter( + (r) => r.iso_3166_1 !== 'all' && r.iso_3166_1 !== 'default' + ); + } else if (newSelectedRegions.length === 0 && !disableAll) { + // If everything deselected, fallback to All (or Default if user setting) + finalSelection = isUserSetting ? [defaultRegion] : [allRegion]; + } + + setSelectedRegionsList(finalSelection); + + if (onChange) { + const isNowAll = finalSelection.find((r) => r.iso_3166_1 === 'all'); + const isNowDefault = finalSelection.find( + (r) => r.iso_3166_1 === 'default' + ); + + if (isNowAll) { + onChange(name, 'all'); + } else if (isNowDefault) { onChange(name, ''); + } else { + onChange(name, finalSelection.map((r) => r.iso_3166_1).join('|')); } } - }, [onChange, selectedRegion, name, regions]); + }; + + const isRegionSelected = (regionCode: string) => { + return selectedRegionsList.some((r) => r.iso_3166_1 === regionCode); + }; return (
- + {({ open }) => (
- {((selectedRegion && - countries.includes(selectedRegion?.iso_3166_1)) || - (isUserSetting && - !selectedRegion && - regionValue && - countries.includes(regionValue))) && ( - - - - )} + {selectedRegionsList.length === 1 && + selectedRegionsList[0].iso_3166_1 !== 'all' && + selectedRegionsList[0].iso_3166_1 !== 'default' && + countries.includes(selectedRegionsList[0].iso_3166_1) && ( + + + + )} + {selectedRegionsList.length === 1 && + selectedRegionsList[0].iso_3166_1 === 'default' && + regions && + regionValue && + countries.includes(regionValue) && ( + + + + )} - {selectedRegion && selectedRegion.iso_3166_1 !== 'all' - ? regionName(selectedRegion.iso_3166_1) - : isUserSetting && selectedRegion?.iso_3166_1 !== 'all' - ? intl.formatMessage(messages.regionServerDefault, { - region: regionValue - ? regionName(regionValue) - : intl.formatMessage(messages.regionDefault), + {selectedRegionsList.length > 1 + ? selectedRegionsList.map((region, index) => { + const isLast = index === selectedRegionsList.length - 1; + if (index > 1) return null; // Only show first 2 + if (index === 1 && selectedRegionsList.length > 2) { + return ( + + {' '} + + {selectedRegionsList.length - 1} more + + ); + } + return ( + + {countries.includes(region.iso_3166_1) && ( + + )} + {regionName(region.iso_3166_1)} + {!isLast && selectedRegionsList.length <= 2 && ','} + + ); }) + : selectedRegionsList.length === 1 + ? selectedRegionsList[0].iso_3166_1 === 'all' + ? intl.formatMessage(messages.regionDefault) + : selectedRegionsList[0].iso_3166_1 === 'default' + ? `${intl.formatMessage(messages.regionServerDefault)}${ + regionValue && regionName(regionValue) + ? ` (${regionName(regionValue)})` + : '' + }` + : regionName(selectedRegionsList[0].iso_3166_1) : intl.formatMessage(messages.regionDefault)} @@ -143,8 +241,8 @@ const RegionSelector = ({ className="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5" > {isUserSetting && ( - - {({ selected, active }) => ( + + {({ active }) => (
- {intl.formatMessage(messages.regionServerDefault, { - region: regionValue - ? regionName(regionValue) - : intl.formatMessage(messages.regionDefault), - })} + {intl.formatMessage(messages.regionServerDefault)} + {regionValue && regionName(regionValue) + ? ` (${regionName(regionValue)})` + : ''} - {selected && ( + {isRegionSelected('default') && ( )} {!disableAll && ( - - {({ selected, active }) => ( + + {({ active }) => (
{intl.formatMessage(messages.regionDefault)} - {selected && ( + {isRegionSelected('all') && ( ( - {({ selected, active }) => ( + {({ active }) => (
{regionName(region.iso_3166_1)} - {selected && ( + {isRegionSelected(region.iso_3166_1) && ( { locale: data?.locale ?? 'en', discoverRegion: data?.discoverRegion, originalLanguage: data?.originalLanguage, - streamingRegion: data?.streamingRegion || 'US', + streamingRegion: data?.streamingRegion ?? 'US', blacklistedTags: data?.blacklistedTags, blacklistedTagsLimit: data?.blacklistedTagsLimit || 50, partialRequestsEnabled: data?.partialRequestsEnabled, diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 34963834d..662e01950 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -458,7 +458,8 @@ "components.QuotaSelector.tvRequests": "{quotaLimit} {seasons} per {quotaDays} {days}", "components.QuotaSelector.unlimited": "Unlimited", "components.RegionSelector.regionDefault": "All Regions", - "components.RegionSelector.regionServerDefault": "Default ({region})", + "components.RegionSelector.regionServerDefault": "Default", + "components.RegionSelector.regionsSelected": "{count, plural, one {Region} other {Regions}} Selected", "components.RequestBlock.approve": "Approve Request", "components.RequestBlock.decline": "Decline Request", "components.RequestBlock.delete": "Delete Request",