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

This commit is contained in:
Daniel Carter
2021-02-22 16:39:25 +09:00
committed by GitHub
parent 8701fb20d0
commit b557c06b0a
21 changed files with 787 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"`);
}
}

View File

@@ -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 data = await tmdb.getUpcomingMovies({
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.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),

View File

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

View File

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