From b557c06b0a78f5df5f64a05dc1e4511dae72df4f Mon Sep 17 00:00:00 2001 From: Daniel Carter Date: Mon, 22 Feb 2021 16:39:25 +0900 Subject: [PATCH] feat(regions): add region/original language setting for filtering Discover (#732) (#942) --- overseerr-api.yml | 82 ++++++++ package.json | 1 + server/api/themoviedb/index.ts | 69 ++++++- server/api/themoviedb/interfaces.ts | 11 ++ server/entity/UserSettings.ts | 6 + .../interfaces/api/userSettingsInterfaces.ts | 6 + server/lib/settings.ts | 15 ++ ...1613955393450-UpdateUserSettingsRegions.ts | 32 +++ server/routes/discover.ts | 77 +++++++- server/routes/index.ts | 17 ++ server/routes/user/usersettings.ts | 27 ++- .../Discover/DiscoverTvUpcoming.tsx | 93 +++++++++ src/components/Discover/index.tsx | 19 +- src/components/RegionSelector/index.tsx | 185 ++++++++++++++++++ src/components/Settings/SettingsMain.tsx | 63 +++++- .../UserGeneralSettings/index.tsx | 75 ++++++- src/i18n/locale/en.json | 10 + src/pages/discover/tv.tsx | 9 - src/pages/discover/tv/index.tsx | 9 + src/pages/discover/tv/upcoming.tsx | 9 + yarn.lock | 5 + 21 files changed, 787 insertions(+), 33 deletions(-) create mode 100644 server/migration/1613955393450-UpdateUserSettingsRegions.ts create mode 100644 src/components/Discover/DiscoverTvUpcoming.tsx create mode 100644 src/components/RegionSelector/index.tsx delete mode 100644 src/pages/discover/tv.tsx create mode 100644 src/pages/discover/tv/index.tsx create mode 100644 src/pages/discover/tv/upcoming.tsx diff --git a/overseerr-api.yml b/overseerr-api.yml index 40971a319..90b90d11d 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -3322,6 +3322,45 @@ paths: type: array items: $ref: '#/components/schemas/TvResult' + /discover/tv/upcoming: + get: + summary: Discover Upcoming TV shows + description: Returns a list of upcoming TV shows in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/TvResult' /discover/trending: get: summary: Trending movies and TV @@ -4275,6 +4314,49 @@ paths: type: array items: $ref: '#/components/schemas/SonarrSeries' + /regions: + get: + summary: Regions supported by TMDb + description: Returns a list of regions in a JSON object. + tags: + - tmdb + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + iso_3166_1: + type: string + example: US + english_name: + type: string + example: United States of America + /languages: + get: + summary: Languages supported by TMDb + description: Returns a list of languages in a JSON object. + tags: + - tmdb + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + iso_639_1: + type: string + example: en + english_name: + type: string + example: English + name: + type: string + example: English security: - cookieAuth: [] diff --git a/package.json b/package.json index 9c14eb18f..d0fb718cd 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "bowser": "^2.11.0", "connect-typeorm": "^1.1.4", "cookie-parser": "^1.4.5", + "country-code-emoji": "^2.2.0", "csurf": "^1.11.0", "email-templates": "^8.0.3", "express": "^4.17.1", diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 689b3a87f..b7bfeb92c 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -1,11 +1,14 @@ +import { sortBy } from 'lodash'; import cacheManager from '../../lib/cache'; import ExternalAPI from '../externalapi'; import { TmdbCollection, TmdbExternalIdResponse, + TmdbLanguage, TmdbMovieDetails, TmdbPersonCombinedCredits, TmdbPersonDetail, + TmdbRegion, TmdbSearchMovieResponse, TmdbSearchMultiResponse, TmdbSearchTvResponse, @@ -25,6 +28,8 @@ interface DiscoverMovieOptions { page?: number; includeAdult?: boolean; language?: string; + primaryReleaseDateGte?: string; + primaryReleaseDateLte?: string; sortBy?: | 'popularity.asc' | 'popularity.desc' @@ -45,6 +50,9 @@ interface DiscoverMovieOptions { interface DiscoverTvOptions { page?: number; language?: string; + firstAirDateGte?: string; + firstAirDateLte?: string; + includeEmptyReleaseDate?: boolean; sortBy?: | 'popularity.asc' | 'popularity.desc' @@ -57,7 +65,12 @@ interface DiscoverTvOptions { } class TheMovieDb extends ExternalAPI { - constructor() { + private region?: string; + private originalLanguage?: string; + constructor({ + region, + originalLanguage, + }: { region?: string; originalLanguage?: string } = {}) { super( 'https://api.themoviedb.org/3', { @@ -67,6 +80,8 @@ class TheMovieDb extends ExternalAPI { nodeCache: cacheManager.getCache('tmdb').data, } ); + this.region = region; + this.originalLanguage = originalLanguage; } public searchMulti = async ({ @@ -343,6 +358,8 @@ class TheMovieDb extends ExternalAPI { page = 1, includeAdult = false, language = 'en', + primaryReleaseDateGte, + primaryReleaseDateLte, }: DiscoverMovieOptions = {}): Promise => { try { const data = await this.get('/discover/movie', { @@ -351,6 +368,11 @@ class TheMovieDb extends ExternalAPI { page, include_adult: includeAdult, language, + with_release_type: '3|2', + region: this.region, + with_original_language: this.originalLanguage, + 'primary_release_date.gte': primaryReleaseDateGte, + 'primary_release_date.lte': primaryReleaseDateLte, }, }); @@ -363,7 +385,10 @@ class TheMovieDb extends ExternalAPI { public getDiscoverTv = async ({ sortBy = 'popularity.desc', page = 1, - language = 'en', + language = 'en-US', + firstAirDateGte, + firstAirDateLte, + includeEmptyReleaseDate = false, }: DiscoverTvOptions = {}): Promise => { try { const data = await this.get('/discover/tv', { @@ -371,6 +396,11 @@ class TheMovieDb extends ExternalAPI { sort_by: sortBy, page, language, + region: this.region, + 'first_air_date.gte': firstAirDateGte, + 'first_air_date.lte': firstAirDateLte, + with_original_language: this.originalLanguage, + include_null_first_air_dates: includeEmptyReleaseDate, }, }); @@ -394,6 +424,8 @@ class TheMovieDb extends ExternalAPI { params: { page, language, + region: this.region, + originalLanguage: this.originalLanguage, }, } ); @@ -420,6 +452,7 @@ class TheMovieDb extends ExternalAPI { params: { page, language, + region: this.region, }, } ); @@ -594,6 +627,38 @@ class TheMovieDb extends ExternalAPI { throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`); } } + + public async getRegions(): Promise { + try { + const data = await this.get( + '/configuration/countries', + {}, + 86400 // 24 hours + ); + + const regions = sortBy(data, 'english_name'); + + return regions; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`); + } + } + + public async getLanguages(): Promise { + try { + const data = await this.get( + '/configuration/languages', + {}, + 86400 // 24 hours + ); + + const languages = sortBy(data, 'english_name'); + + return languages; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`); + } + } } export default TheMovieDb; diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index 383380cb4..1b0da07ec 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -370,3 +370,14 @@ export interface TmdbCollection { backdrop_path?: string; parts: TmdbMovieResult[]; } + +export interface TmdbRegion { + iso_3166_1: string; + english_name: string; +} + +export interface TmdbLanguage { + iso_639_1: string; + english_name: string; + name: string; +} diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index 48fa53283..163de1346 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -25,4 +25,10 @@ export class UserSettings { @Column({ nullable: true }) public discordId?: string; + + @Column({ nullable: true }) + public region?: string; + + @Column({ nullable: true }) + public originalLanguage?: string; } diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 7c9cfbbe0..023b76315 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -1,3 +1,9 @@ +export interface UserSettingsGeneralResponse { + username?: string; + region?: string; + originalLanguage?: string; +} + export interface UserSettingsNotificationsResponse { enableNotifications: boolean; discordId?: string; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 2d0e9c87d..52663bda9 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -10,6 +10,17 @@ export interface Library { enabled: boolean; } +export interface Region { + iso_3166_1: string; + english_name: string; +} + +export interface Language { + iso_639_1: string; + english_name: string; + name: string; +} + export interface PlexSettings { name: string; machineId?: string; @@ -58,6 +69,8 @@ export interface MainSettings { defaultPermissions: number; hideAvailable: boolean; localLogin: boolean; + region: string; + originalLanguage: string; trustProxy: boolean; } @@ -177,6 +190,8 @@ class Settings { defaultPermissions: Permission.REQUEST, hideAvailable: false, localLogin: true, + region: '', + originalLanguage: '', trustProxy: false, }, plex: { diff --git a/server/migration/1613955393450-UpdateUserSettingsRegions.ts b/server/migration/1613955393450-UpdateUserSettingsRegions.ts new file mode 100644 index 000000000..17c25ec29 --- /dev/null +++ b/server/migration/1613955393450-UpdateUserSettingsRegions.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateUserSettingsRegions1613955393450 + implements MigrationInterface { + name = 'UpdateUserSettingsRegions1613955393450'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "enableNotifications", "discordId", "userId") SELECT "id", "enableNotifications", "discordId", "userId" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "enableNotifications", "discordId", "userId") SELECT "id", "enableNotifications", "discordId", "userId" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/routes/discover.ts b/server/routes/discover.ts index ec2116aa0..5b9d1afce 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -4,11 +4,17 @@ import { mapMovieResult, mapTvResult, mapPersonResult } from '../models/Search'; import Media from '../entity/Media'; import { isMovie, isPerson } from '../utils/typeHelpers'; import { MediaType } from '../constants/media'; +import { getSettings } from '../lib/settings'; const discoverRoutes = Router(); discoverRoutes.get('/movies', async (req, res) => { - const tmdb = new TheMovieDb(); + const settings = getSettings(); + const tmdb = new TheMovieDb({ + region: req.user?.settings?.region ?? settings.main.region, + originalLanguage: + req.user?.settings?.originalLanguage ?? settings.main.originalLanguage, + }); const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), @@ -35,11 +41,23 @@ discoverRoutes.get('/movies', async (req, res) => { }); discoverRoutes.get('/movies/upcoming', async (req, res) => { - const tmdb = new TheMovieDb(); + const settings = getSettings(); + const tmdb = new TheMovieDb({ + region: req.user?.settings?.region ?? settings.main.region, + originalLanguage: + req.user?.settings?.originalLanguage ?? settings.main.originalLanguage, + }); + + const now = new Date(); + const offset = now.getTimezoneOffset(); + const date = new Date(now.getTime() - offset * 60 * 1000) + .toISOString() + .split('T')[0]; - const data = await tmdb.getUpcomingMovies({ + const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), language: req.query.language as string, + primaryReleaseDateGte: date, }); const media = await Media.getRelatedMedia( @@ -62,7 +80,12 @@ discoverRoutes.get('/movies/upcoming', async (req, res) => { }); discoverRoutes.get('/tv', async (req, res) => { - const tmdb = new TheMovieDb(); + const settings = getSettings(); + const tmdb = new TheMovieDb({ + region: req.user?.settings?.region ?? settings.main.region, + originalLanguage: + req.user?.settings?.originalLanguage ?? settings.main.originalLanguage, + }); const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), @@ -88,8 +111,52 @@ discoverRoutes.get('/tv', async (req, res) => { }); }); +discoverRoutes.get('/tv/upcoming', async (req, res) => { + const settings = getSettings(); + const tmdb = new TheMovieDb({ + region: req.user?.settings?.region ?? settings.main.region, + originalLanguage: + req.user?.settings?.originalLanguage ?? settings.main.originalLanguage, + }); + + const now = new Date(); + const offset = now.getTimezoneOffset(); + const date = new Date(now.getTime() - offset * 60 * 1000) + .toISOString() + .split('T')[0]; + + const data = await tmdb.getDiscoverTv({ + page: Number(req.query.page), + language: req.query.language as string, + firstAirDateGte: date, + }); + + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + results: data.results.map((result) => + mapTvResult( + result, + media.find( + (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV + ) + ) + ), + }); +}); + discoverRoutes.get('/trending', async (req, res) => { - const tmdb = new TheMovieDb(); + const settings = getSettings(); + const tmdb = new TheMovieDb({ + region: req.user?.settings?.region ?? settings.main.region, + originalLanguage: + req.user?.settings?.originalLanguage ?? settings.main.originalLanguage, + }); const data = await tmdb.getAllTrending({ page: Number(req.query.page), diff --git a/server/routes/index.ts b/server/routes/index.ts index 918b400db..7527c0304 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -16,6 +16,7 @@ import collectionRoutes from './collection'; import { getAppVersion, getCommitTag } from '../utils/appVersion'; import serviceRoutes from './service'; import { appDataStatus, appDataPath } from '../utils/appDataVolume'; +import TheMovieDb from '../api/themoviedb'; const router = Router(); @@ -57,6 +58,22 @@ router.use('/collection', isAuthenticated(), collectionRoutes); router.use('/service', isAuthenticated(), serviceRoutes); router.use('/auth', authRoutes); +router.get('/regions', isAuthenticated(), async (req, res) => { + const tmdb = new TheMovieDb(); + + const regions = await tmdb.getRegions(); + + return res.status(200).json(regions); +}); + +router.get('/languages', isAuthenticated(), async (req, res) => { + const tmdb = new TheMovieDb(); + + const languages = await tmdb.getLanguages(); + + return res.status(200).json(languages); +}); + router.get('/', (_req, res) => { return res.status(200).json({ api: 'Overseerr API', diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 93e7ab2ca..c2e075119 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -2,7 +2,10 @@ import { Router } from 'express'; import { getRepository } from 'typeorm'; import { User } from '../../entity/User'; import { UserSettings } from '../../entity/UserSettings'; -import { UserSettingsNotificationsResponse } from '../../interfaces/api/userSettingsInterfaces'; +import { + UserSettingsGeneralResponse, + UserSettingsNotificationsResponse, +} from '../../interfaces/api/userSettingsInterfaces'; import { Permission } from '../../lib/permissions'; import logger from '../../logger'; import { isAuthenticated } from '../../middleware/auth'; @@ -25,7 +28,7 @@ const isOwnProfileOrAdmin = (): Middleware => { const userSettingsRoutes = Router({ mergeParams: true }); -userSettingsRoutes.get<{ id: string }, { username?: string }>( +userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( '/main', isOwnProfileOrAdmin(), async (req, res, next) => { @@ -40,7 +43,11 @@ userSettingsRoutes.get<{ id: string }, { username?: string }>( return next({ status: 404, message: 'User not found.' }); } - return res.status(200).json({ username: user.username }); + return res.status(200).json({ + username: user.username, + region: user.settings?.region, + originalLanguage: user.settings?.originalLanguage, + }); } catch (e) { next({ status: 500, message: e.message }); } @@ -49,8 +56,8 @@ userSettingsRoutes.get<{ id: string }, { username?: string }>( userSettingsRoutes.post< { id: string }, - { username?: string }, - { username?: string } + UserSettingsGeneralResponse, + UserSettingsGeneralResponse >('/main', isOwnProfileOrAdmin(), async (req, res, next) => { const userRepository = getRepository(User); @@ -64,6 +71,16 @@ userSettingsRoutes.post< } user.username = req.body.username; + if (!user.settings) { + user.settings = new UserSettings({ + user: req.user, + region: req.body.region, + originalLanguage: req.body.originalLanguage, + }); + } else { + user.settings.region = req.body.region; + user.settings.originalLanguage = req.body.originalLanguage; + } await userRepository.save(user); diff --git a/src/components/Discover/DiscoverTvUpcoming.tsx b/src/components/Discover/DiscoverTvUpcoming.tsx new file mode 100644 index 000000000..6e08c29db --- /dev/null +++ b/src/components/Discover/DiscoverTvUpcoming.tsx @@ -0,0 +1,93 @@ +import React, { useContext } from 'react'; +import { useSWRInfinite } from 'swr'; +import type { TvResult } from '../../../server/models/Search'; +import ListView from '../Common/ListView'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { LanguageContext } from '../../context/LanguageContext'; +import Header from '../Common/Header'; +import useSettings from '../../hooks/useSettings'; +import { MediaStatus } from '../../../server/constants/media'; +import PageTitle from '../Common/PageTitle'; + +const messages = defineMessages({ + upcomingtv: 'Upcoming Series', +}); + +interface SearchResult { + page: number; + totalResults: number; + totalPages: number; + results: TvResult[]; +} + +const DiscoverTvUpcoming: React.FC = () => { + const intl = useIntl(); + const settings = useSettings(); + const { locale } = useContext(LanguageContext); + const { data, error, size, setSize } = useSWRInfinite( + (pageIndex: number, previousPageData: SearchResult | null) => { + if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { + return null; + } + + return `/api/v1/discover/tv/upcoming?page=${ + pageIndex + 1 + }&language=${locale}`; + }, + { + initialSize: 3, + } + ); + + const isLoadingInitialData = !data && !error; + const isLoadingMore = + isLoadingInitialData || + (size > 0 && data && typeof data[size - 1] === 'undefined'); + + const fetchMore = () => { + setSize(size + 1); + }; + + if (error) { + return
{error}
; + } + + let titles = (data ?? []).reduce( + (a, v) => [...a, ...v.results], + [] as TvResult[] + ); + + if (settings.currentSettings.hideAvailable) { + titles = titles.filter( + (i) => + i.mediaInfo?.status !== MediaStatus.AVAILABLE && + i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + } + + const isEmpty = !isLoadingInitialData && titles?.length === 0; + const isReachingEnd = + isEmpty || (data && data[data.length - 1]?.results.length < 20); + + return ( + <> + +
+
+ +
+
+ 0) + } + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverTvUpcoming; diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index e883d4ae0..b53e9d3dc 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -15,6 +15,7 @@ const messages = defineMessages({ recentrequests: 'Recent Requests', popularmovies: 'Popular Movies', populartv: 'Popular Series', + upcomingtv: 'Upcoming Series', recentlyAdded: 'Recently Added', nopending: 'No Pending Requests', upcoming: 'Upcoming Movies', @@ -97,12 +98,6 @@ const Discover: React.FC = () => { placeholder={} emptyMessage={intl.formatMessage(messages.nopending)} /> - { url="/api/v1/discover/movies" linkUrl="/discover/movies" /> + + ); }; diff --git a/src/components/RegionSelector/index.tsx b/src/components/RegionSelector/index.tsx new file mode 100644 index 000000000..3c27a419f --- /dev/null +++ b/src/components/RegionSelector/index.tsx @@ -0,0 +1,185 @@ +import React, { useEffect, useState } from 'react'; +import { Listbox, Transition } from '@headlessui/react'; +import { countryCodeEmoji } from 'country-code-emoji'; +import useSWR from 'swr'; +import type { Region } from '../../../server/lib/settings'; +import { defineMessages, useIntl } from 'react-intl'; + +const messages = defineMessages({ + regionDefault: 'All', +}); + +interface RegionSelectorProps { + value: string; + name: string; + onChange?: (fieldName: string, region: string) => void; +} + +const RegionSelector: React.FC = ({ + name, + value, + onChange, +}) => { + const intl = useIntl(); + const { data: regions } = useSWR('/api/v1/regions'); + const [selectedRegion, setSelectedRegion] = useState(null); + + useEffect(() => { + if (regions && value) { + const matchedRegion = regions.find( + (region) => region.iso_3166_1 === value + ); + setSelectedRegion(matchedRegion ?? null); + } + }, [value, regions]); + + useEffect(() => { + if (onChange && regions) { + onChange(name, selectedRegion?.iso_3166_1 ?? ''); + } + }, [onChange, selectedRegion, name, regions]); + + return ( +
+
+ + {({ open }) => ( +
+ + + {selectedRegion && ( + + {countryCodeEmoji(selectedRegion.iso_3166_1)} + + )} + + {selectedRegion + ? intl.formatDisplayName(selectedRegion.iso_3166_1, { + type: 'region', + }) + : intl.formatMessage(messages.regionDefault)} + + + + + + + + + + + + + {({ selected, active }) => ( +
+ + {intl.formatMessage(messages.regionDefault)} + + {selected && ( + + + + + + )} +
+ )} +
+ {regions?.map((region) => ( + + {({ selected, active }) => ( +
+ + {countryCodeEmoji(region.iso_3166_1)} + + + {intl.formatDisplayName(region.iso_3166_1, { + type: 'region', + })} + + {selected && ( + + + + + + )} +
+ )} +
+ ))} +
+
+
+ )} +
+
+
+ ); +}; + +export default RegionSelector; diff --git a/src/components/Settings/SettingsMain.tsx b/src/components/Settings/SettingsMain.tsx index 9fa92d07f..4466f828d 100644 --- a/src/components/Settings/SettingsMain.tsx +++ b/src/components/Settings/SettingsMain.tsx @@ -1,7 +1,7 @@ import React from 'react'; import useSWR from 'swr'; import LoadingSpinner from '../Common/LoadingSpinner'; -import type { MainSettings } from '../../../server/lib/settings'; +import type { MainSettings, Language } from '../../../server/lib/settings'; import CopyButton from './CopyButton'; import { Form, Formik, Field } from 'formik'; import axios from 'axios'; @@ -13,6 +13,7 @@ import Badge from '../Common/Badge'; import globalMessages from '../../i18n/globalMessages'; import PermissionEdit from '../PermissionEdit'; import * as Yup from 'yup'; +import RegionSelector from '../RegionSelector'; const messages = defineMessages({ generalsettings: 'General Settings', @@ -23,6 +24,12 @@ const messages = defineMessages({ apikey: 'API Key', applicationTitle: 'Application Title', applicationurl: 'Application URL', + region: 'Discover Region', + regionTip: + 'Filter content by region (only applies to the "Popular" and "Upcoming" categories)', + originallanguage: 'Discover Language', + originallanguageTip: + 'Filter content by original language (only applies to the "Popular" and "Upcoming" categories)', toastApiKeySuccess: 'New API key generated!', toastApiKeyFailure: 'Something went wrong while generating a new API key.', toastSettingsSuccess: 'Settings successfully saved!', @@ -50,6 +57,9 @@ const SettingsMain: React.FC = () => { const { data, error, revalidate } = useSWR( '/api/v1/settings/main' ); + const { data: languages, error: languagesError } = useSWR( + '/api/v1/languages' + ); const MainSettingsSchema = Yup.object().shape({ applicationTitle: Yup.string().required( intl.formatMessage(messages.validationApplicationTitle) @@ -85,7 +95,7 @@ const SettingsMain: React.FC = () => { } }; - if (!data && !error) { + if (!data && !error && !languages && !languagesError) { return ; } @@ -108,6 +118,8 @@ const SettingsMain: React.FC = () => { defaultPermissions: data?.defaultPermissions ?? 0, hideAvailable: data?.hideAvailable, localLogin: data?.localLogin, + region: data?.region, + originalLanguage: data?.originalLanguage, trustProxy: data?.trustProxy, }} enableReinitialize @@ -121,6 +133,8 @@ const SettingsMain: React.FC = () => { defaultPermissions: values.defaultPermissions, hideAvailable: values.hideAvailable, localLogin: values.localLogin, + region: values.region, + originalLanguage: values.originalLanguage, trustProxy: values.trustProxy, }); @@ -263,6 +277,51 @@ const SettingsMain: React.FC = () => { /> +
+ +
+ +
+
+
+ +
+
+ + + {languages?.map((language) => ( + + ))} + +
+
+
+
+ +
+ +
+
+
+ +
+
+ + + {languages?.map((language) => ( + + ))} + +
+
+
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index fc8d4a4aa..43632edbf 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -25,6 +25,7 @@ "components.Discover.trending": "Trending", "components.Discover.upcoming": "Upcoming Movies", "components.Discover.upcomingmovies": "Upcoming Movies", + "components.Discover.upcomingtv": "Upcoming Series", "components.Layout.LanguagePicker.changelanguage": "Change Language", "components.Layout.SearchInput.searchPlaceholder": "Search Movies & TV", "components.Layout.Sidebar.dashboard": "Discover", @@ -137,6 +138,7 @@ "components.PlexLoginButton.loading": "Loading…", "components.PlexLoginButton.signingin": "Signing in…", "components.PlexLoginButton.signinwithplex": "Sign In", + "components.RegionSelector.regionDefault": "All", "components.RequestBlock.profilechanged": "Quality Profile", "components.RequestBlock.requestoverrides": "Request Overrides", "components.RequestBlock.rootfolder": "Root Folder", @@ -514,6 +516,8 @@ "components.Settings.notificationsettingsfailed": "Notification settings failed to save.", "components.Settings.notificationsettingssaved": "Notification settings saved!", "components.Settings.notrunning": "Not Running", + "components.Settings.originallanguage": "Discover Language", + "components.Settings.originallanguageTip": "Filter content by original language (only applies to the \"Popular\" and \"Upcoming\" categories)", "components.Settings.plexlibraries": "Plex Libraries", "components.Settings.plexlibrariesDescription": "The libraries Overseerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.", "components.Settings.plexsettings": "Plex Settings", @@ -521,6 +525,8 @@ "components.Settings.port": "Port", "components.Settings.radarrSettingsDescription": "Configure your Radarr connection below. You can have multiple Radarr configurations, but only two can be active as defaults at any time (one for standard HD and one for 4K). Administrators can override the server which is used for new requests.", "components.Settings.radarrsettings": "Radarr Settings", + "components.Settings.region": "Discover Region", + "components.Settings.regionTip": "Filter content by region (only applies to the \"Popular\" and \"Upcoming\" categories)", "components.Settings.save": "Save Changes", "components.Settings.saving": "Saving…", "components.Settings.serverConnected": "connected", @@ -671,7 +677,11 @@ "components.UserProfile.UserSettings.UserGeneralSettings.displayName": "Display Name", "components.UserProfile.UserSettings.UserGeneralSettings.generalsettings": "General Settings", "components.UserProfile.UserSettings.UserGeneralSettings.localuser": "Local User", + "components.UserProfile.UserSettings.UserGeneralSettings.originallanguage": "Discover Language", + "components.UserProfile.UserSettings.UserGeneralSettings.originallanguageTip": "Filter content by original language (only applies to the \"Popular\" and \"Upcoming\" categories)", "components.UserProfile.UserSettings.UserGeneralSettings.plexuser": "Plex User", + "components.UserProfile.UserSettings.UserGeneralSettings.region": "Discover Region", + "components.UserProfile.UserSettings.UserGeneralSettings.regionTip": "Filter content by region (only applies to the \"Popular\" and \"Upcoming\" categories)", "components.UserProfile.UserSettings.UserGeneralSettings.save": "Save Changes", "components.UserProfile.UserSettings.UserGeneralSettings.saving": "Saving…", "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailure": "Something went wrong while saving settings.", diff --git a/src/pages/discover/tv.tsx b/src/pages/discover/tv.tsx deleted file mode 100644 index 03c1d6f80..000000000 --- a/src/pages/discover/tv.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import { NextPage } from 'next'; -import DiscoverTv from '../../components/Discover/DiscoverTv'; - -const DiscoverMoviesPage: NextPage = () => { - return ; -}; - -export default DiscoverMoviesPage; diff --git a/src/pages/discover/tv/index.tsx b/src/pages/discover/tv/index.tsx new file mode 100644 index 000000000..193694402 --- /dev/null +++ b/src/pages/discover/tv/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { NextPage } from 'next'; +import DiscoverTv from '../../../components/Discover/DiscoverTv'; + +const DiscoverTvPage: NextPage = () => { + return ; +}; + +export default DiscoverTvPage; diff --git a/src/pages/discover/tv/upcoming.tsx b/src/pages/discover/tv/upcoming.tsx new file mode 100644 index 000000000..62af7996b --- /dev/null +++ b/src/pages/discover/tv/upcoming.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { NextPage } from 'next'; +import DiscoverTvUpcoming from '../../../components/Discover/DiscoverTvUpcoming'; + +const DiscoverTvPage: NextPage = () => { + return ; +}; + +export default DiscoverTvPage; diff --git a/yarn.lock b/yarn.lock index c7e0a3545..b432fa9ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4593,6 +4593,11 @@ cosmiconfig@^7.0.0: path-type "^4.0.0" yaml "^1.10.0" +country-code-emoji@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/country-code-emoji/-/country-code-emoji-2.2.0.tgz#afc99b8bbaff9cb038e370dc46faabbd7af48f64" + integrity sha512-iK7tw8pRbFIad7a3UDbx13SJpZj4ZReozc6oW6K6Wu4sAphQkJVxK8qfaPjFIXp22RoP/238WEDrKpIWxxI9CQ== + country-language@^0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/country-language/-/country-language-0.1.7.tgz#7870f4ba125db9a6071f19737bd9ef9343ae35db"