feat(regions): add region/original language setting for filtering Discover (#732) (#942)

pull/989/head
Daniel Carter 3 years ago committed by GitHub
parent 8701fb20d0
commit b557c06b0a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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: []

@ -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",

@ -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<TmdbSearchMovieResponse> => {
try {
const data = await this.get<TmdbSearchMovieResponse>('/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<TmdbSearchTvResponse> => {
try {
const data = await this.get<TmdbSearchTvResponse>('/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<TmdbRegion[]> {
try {
const data = await this.get<TmdbRegion[]>(
'/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<TmdbLanguage[]> {
try {
const data = await this.get<TmdbLanguage[]>(
'/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;

@ -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;
}

@ -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;
}

@ -1,3 +1,9 @@
export interface UserSettingsGeneralResponse {
username?: string;
region?: string;
originalLanguage?: string;
}
export interface UserSettingsNotificationsResponse {
enableNotifications: boolean;
discordId?: string;

@ -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: {

@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateUserSettingsRegions1613955393450
implements MigrationInterface {
name = 'UpdateUserSettingsRegions1613955393450';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

@ -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),

@ -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',

@ -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);

@ -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<SearchResult>(
(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 <div>{error}</div>;
}
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 (
<>
<PageTitle title={intl.formatMessage(messages.upcomingtv)} />
<div className="mt-1 mb-5">
<Header>
<FormattedMessage {...messages.upcomingtv} />
</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isReachingEnd={isReachingEnd}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverTvUpcoming;

@ -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={<RequestCard.Placeholder />}
emptyMessage={intl.formatMessage(messages.nopending)}
/>
<MediaSlider
sliderKey="upcoming"
title={intl.formatMessage(messages.upcoming)}
linkUrl="/discover/movies/upcoming"
url="/api/v1/discover/movies/upcoming"
/>
<MediaSlider
sliderKey="trending"
title={intl.formatMessage(messages.trending)}
@ -115,12 +110,24 @@ const Discover: React.FC = () => {
url="/api/v1/discover/movies"
linkUrl="/discover/movies"
/>
<MediaSlider
sliderKey="upcoming"
title={intl.formatMessage(messages.upcoming)}
linkUrl="/discover/movies/upcoming"
url="/api/v1/discover/movies/upcoming"
/>
<MediaSlider
sliderKey="popular-tv"
title={intl.formatMessage(messages.populartv)}
url="/api/v1/discover/tv"
linkUrl="/discover/tv"
/>
<MediaSlider
sliderKey="upcoming-tv"
title={intl.formatMessage(messages.upcomingtv)}
url="/api/v1/discover/tv/upcoming"
linkUrl="/discover/tv/upcoming"
/>
</>
);
};

@ -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<RegionSelectorProps> = ({
name,
value,
onChange,
}) => {
const intl = useIntl();
const { data: regions } = useSWR<Region[]>('/api/v1/regions');
const [selectedRegion, setSelectedRegion] = useState<Region | null>(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 (
<div className="relative z-40 flex max-w-lg">
<div className="w-full">
<Listbox as="div" value={selectedRegion} onChange={setSelectedRegion}>
{({ open }) => (
<div className="relative">
<span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="relative flex items-center w-full py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
{selectedRegion && (
<span className="h-4 mr-2 overflow-hidden text-lg leading-4">
{countryCodeEmoji(selectedRegion.iso_3166_1)}
</span>
)}
<span className="block truncate">
{selectedRegion
? intl.formatDisplayName(selectedRegion.iso_3166_1, {
type: 'region',
})
: intl.formatMessage(messages.regionDefault)}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 20 20"
className="w-5 h-5 text-gray-500"
>
<path
stroke="#6b7280"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M6 8l4 4 4-4"
/>
</svg>
</span>
</Listbox.Button>
</span>
<Transition
show={open}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute w-full mt-1 bg-gray-800 rounded-md shadow-lg"
>
<Listbox.Options
static
className="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5"
>
<Listbox.Option value={null}>
{({ selected, active }) => (
<div
className={`${
active ? 'text-white bg-indigo-600' : 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4`}
>
<span
className={`${
selected ? 'font-semibold' : 'font-normal'
} block truncate`}
>
{intl.formatMessage(messages.regionDefault)}
</span>
{selected && (
<span
className={`${
active ? 'text-white' : 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</div>
)}
</Listbox.Option>
{regions?.map((region) => (
<Listbox.Option key={region.iso_3166_1} value={region}>
{({ selected, active }) => (
<div
className={`${
active
? 'text-white bg-indigo-600'
: 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4 flex items-center`}
>
<span className="mr-2 text-lg">
{countryCodeEmoji(region.iso_3166_1)}
</span>
<span
className={`${
selected ? 'font-semibold' : 'font-normal'
} block truncate`}
>
{intl.formatDisplayName(region.iso_3166_1, {
type: 'region',
})}
</span>
{selected && (
<span
className={`${
active ? 'text-white' : 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</div>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
</div>
</div>
);
};
export default RegionSelector;

@ -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<MainSettings>(
'/api/v1/settings/main'
);
const { data: languages, error: languagesError } = useSWR<Language[]>(
'/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 <LoadingSpinner />;
}
@ -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 = () => {
/>
</div>
</div>
<div className="form-row">
<label htmlFor="region" className="text-label">
<span>{intl.formatMessage(messages.region)}</span>
<span className="label-tip">
{intl.formatMessage(messages.regionTip)}
</span>
</label>
<div className="form-input">
<RegionSelector
value={values.region ?? ''}
name="region"
onChange={setFieldValue}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="originalLanguage" className="text-label">
<span>{intl.formatMessage(messages.originallanguage)}</span>
<span className="label-tip">
{intl.formatMessage(messages.originallanguageTip)}
</span>
</label>
<div className="form-input">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
as="select"
id="originalLanguage"
name="originalLanguage"
>
<option value="">All</option>
{languages?.map((language) => (
<option
key={`language-key-${language.iso_639_1}`}
value={language.iso_639_1}
>
{intl.formatDisplayName(language.iso_639_1, {
type: 'language',
fallback: 'none',
}) ?? language.english_name}
</option>
))}
</Field>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="hideAvailable" className="checkbox-label">
<span className="mr-2">

@ -5,11 +5,13 @@ import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import { Language } from '../../../../../server/lib/settings';
import { UserType, useUser } from '../../../../hooks/useUser';
import Error from '../../../../pages/_error';
import Badge from '../../../Common/Badge';
import Button from '../../../Common/Button';
import LoadingSpinner from '../../../Common/LoadingSpinner';
import RegionSelector from '../../../RegionSelector';
const messages = defineMessages({
generalsettings: 'General Settings',
@ -20,6 +22,12 @@ const messages = defineMessages({
localuser: 'Local User',
toastSettingsSuccess: 'Settings successfully saved!',
toastSettingsFailure: 'Something went wrong while saving settings.',
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)',
});
const UserGeneralSettings: React.FC = () => {
@ -27,15 +35,25 @@ const UserGeneralSettings: React.FC = () => {
const { addToast } = useToasts();
const router = useRouter();
const { user, mutate } = useUser({ id: Number(router.query.userId) });
const { data, error, revalidate } = useSWR<{ username?: string }>(
user ? `/api/v1/user/${user?.id}/settings/main` : null
const { data, error, revalidate } = useSWR<{
username?: string;
region?: string;
originalLanguage?: string;
}>(user ? `/api/v1/user/${user?.id}/settings/main` : null);
const { data: languages, error: languagesError } = useSWR<Language[]>(
'/api/v1/languages'
);
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
if (!languages && !languagesError) {
return <LoadingSpinner />;
}
if (!data || !languages) {
return <Error statusCode={500} />;
}
@ -49,12 +67,16 @@ const UserGeneralSettings: React.FC = () => {
<Formik
initialValues={{
displayName: data?.username,
region: data?.region,
originalLanguage: data?.originalLanguage,
}}
enableReinitialize
onSubmit={async (values) => {
try {
await axios.post(`/api/v1/user/${user?.id}/settings/main`, {
username: values.displayName,
region: values.region,
originalLanguage: values.originalLanguage,
});
addToast(intl.formatMessage(messages.toastSettingsSuccess), {
@ -72,7 +94,7 @@ const UserGeneralSettings: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting }) => {
{({ errors, touched, isSubmitting, values, setFieldValue }) => {
return (
<Form className="section">
<div className="form-row">
@ -109,6 +131,51 @@ const UserGeneralSettings: React.FC = () => {
)}
</div>
</div>
<div className="form-row">
<label htmlFor="displayName" className="text-label">
<span>{intl.formatMessage(messages.region)}</span>
<span className="label-tip">
{intl.formatMessage(messages.regionTip)}
</span>
</label>
<div className="form-input">
<RegionSelector
name="region"
value={values.region ?? ''}
onChange={setFieldValue}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="originalLanguage" className="text-label">
<span>{intl.formatMessage(messages.originallanguage)}</span>
<span className="label-tip">
{intl.formatMessage(messages.originallanguageTip)}
</span>
</label>
<div className="form-input">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
as="select"
id="originalLanguage"
name="originalLanguage"
>
<option value="">All</option>
{languages?.map((language) => (
<option
key={`language-key-${language.iso_639_1}`}
value={language.iso_639_1}
>
{intl.formatDisplayName(language.iso_639_1, {
type: 'language',
fallback: 'none',
}) ?? language.english_name}
</option>
))}
</Field>
</div>
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">

@ -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.",

@ -1,9 +0,0 @@
import React from 'react';
import { NextPage } from 'next';
import DiscoverTv from '../../components/Discover/DiscoverTv';
const DiscoverMoviesPage: NextPage = () => {
return <DiscoverTv />;
};
export default DiscoverMoviesPage;

@ -0,0 +1,9 @@
import React from 'react';
import { NextPage } from 'next';
import DiscoverTv from '../../../components/Discover/DiscoverTv';
const DiscoverTvPage: NextPage = () => {
return <DiscoverTv />;
};
export default DiscoverTvPage;

@ -0,0 +1,9 @@
import React from 'react';
import { NextPage } from 'next';
import DiscoverTvUpcoming from '../../../components/Discover/DiscoverTvUpcoming';
const DiscoverTvPage: NextPage = () => {
return <DiscoverTvUpcoming />;
};
export default DiscoverTvPage;

@ -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"

Loading…
Cancel
Save