diff --git a/.all-contributorsrc b/.all-contributorsrc
index 378c8ee28..664f5658a 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -144,6 +144,15 @@
"code",
"doc"
]
+ },
+ {
+ "login": "mmozeiko",
+ "name": "Mārtiņš Možeiko",
+ "avatar_url": "https://avatars3.githubusercontent.com/u/1665010?v=4",
+ "profile": "https://github.com/mmozeiko",
+ "contributions": [
+ "code"
+ ]
}
],
"badgeTemplate": " -orange.svg\"/> ",
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8d401771f..03a03c2f1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,13 +29,31 @@ jobs:
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, 'skip ci')
runs-on: ubuntu-latest
steps:
- - name: checkout
+ - name: Checkout
uses: actions/checkout@v2
- - name: Build and push to Docker Hub
- uses: docker/build-push-action@v1
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v1
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v1
+ - name: Login to DockerHub
+ uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_PASSWORD }}
- repository: sctx/overseerr
- build_args: COMMIT_TAG=${{ github.sha }}
- tags: develop
+ password: ${{ secrets.DOCKER_TOKEN }}
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v1
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.CR_PAT }}
+ - name: Build and push
+ uses: docker/build-push-action@v2
+ with:
+ context: .
+ file: ./Dockerfile
+ push: true
+ tags: |
+ sctx/overseerr:develop
+ sctx/overseerr:${{ github.sha }}
+ ghcr.io/sct/overseerr:develop
+ ghcr.io/sct/overseerr:${{ github.sha }}
diff --git a/.github/workflows/invalid_template.yml b/.github/workflows/invalid_template.yml
index c98d3174b..647cdeca5 100644
--- a/.github/workflows/invalid_template.yml
+++ b/.github/workflows/invalid_template.yml
@@ -15,5 +15,5 @@ jobs:
issue-comment: >
:wave: @{issue-author}, please edit your issue and follow the template provided.
close-issue: false
- lock-issue: true
+ lock-issue: false
issue-lock-reason: 'resolved'
diff --git a/README.md b/README.md
index 03d6e5276..ef1ca9d1a 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
-
+
@@ -118,6 +118,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
ecelebi29 💻 📖
+ Mārtiņš Možeiko 💻
diff --git a/overseerr-api.yml b/overseerr-api.yml
index 90c2bcb77..d84ac3fac 100644
--- a/overseerr-api.yml
+++ b/overseerr-api.yml
@@ -436,12 +436,7 @@ components:
spokenLanguages:
type: array
items:
- type: object
- properties:
- iso_639_1:
- type: string
- name:
- type: string
+ $ref: '#/components/schemas/SpokenLanguage'
status:
type: string
tagline:
@@ -592,6 +587,10 @@ components:
type: array
items:
$ref: '#/components/schemas/ProductionCompany'
+ spokenLanguages:
+ type: array
+ items:
+ $ref: '#/components/schemas/SpokenLanguage'
seasons:
type: array
items:
@@ -617,6 +616,10 @@ components:
$ref: '#/components/schemas/Crew'
externalIds:
$ref: '#/components/schemas/ExternalIds'
+ keywords:
+ type: array
+ items:
+ $ref: '#/components/schemas/Keyword'
mediaInfo:
$ref: '#/components/schemas/MediaInfo'
MediaRequest:
@@ -961,6 +964,28 @@ components:
type: string
mediaInfo:
$ref: '#/components/schemas/MediaInfo'
+ Keyword:
+ type: object
+ properties:
+ id:
+ type: number
+ example: 1
+ name:
+ type: string
+ example: 'anime'
+ SpokenLanguage:
+ type: object
+ properties:
+ englishName:
+ type: string
+ example: 'English'
+ nullable: true
+ iso_639_1:
+ type: string
+ example: 'en'
+ name:
+ type: string
+ example: 'English'
securitySchemes:
cookieAuth:
type: apiKey
@@ -975,7 +1000,7 @@ paths:
/settings/main:
get:
summary: Returns main settings
- description: Retreives all main settings in JSON format
+ description: Retrieves all main settings in JSON format
tags:
- settings
responses:
@@ -1003,6 +1028,19 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/MainSettings'
+ /settings/main/regenerate:
+ get:
+ summary: Returns main settings with newly generated API Key
+ description: Retreives all main settings in JSON format with new API Key
+ tags:
+ - settings
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MainSettings'
/settings/plex:
get:
summary: Returns plex settings
@@ -1140,7 +1178,7 @@ paths:
/settings/radarr/test:
post:
summary: Test radarr configuration
- description: Test if the provided Radarr congifuration values are valid. Returns profiles and root folders on success
+ description: Test if the provided Radarr configuration values are valid. Returns profiles and root folders on success
tags:
- settings
requestBody:
@@ -1284,7 +1322,7 @@ paths:
/settings/sonarr/test:
post:
summary: Test Sonarr configuration
- description: Test if the provided Sonarr congifuration values are valid. Returns profiles and root folders on success
+ description: Test if the provided Sonarr configuration values are valid. Returns profiles and root folders on success
tags:
- settings
requestBody:
@@ -1387,7 +1425,7 @@ paths:
/settings/initialize:
get:
summary: Set the application as initialized
- description: Sets the app as initalized and allows the user to navigate to pages other than the setup page
+ description: Sets the app as initialized and allows the user to navigate to pages other than the setup page
tags:
- settings
responses:
@@ -1870,6 +1908,51 @@ paths:
- $ref: '#/components/schemas/MovieResult'
- $ref: '#/components/schemas/TvResult'
- $ref: '#/components/schemas/PersonResult'
+ /discover/keyword/{keywordId}/movies:
+ get:
+ summary: Request list of movies from keyword
+ description: Returns list of movies based on provided keyword ID in JSON format
+ tags:
+ - search
+ parameters:
+ - in: path
+ name: keywordId
+ required: true
+ schema:
+ type: number
+ example: 207317
+ - in: query
+ name: page
+ schema:
+ type: number
+ example: 1
+ default: 1
+ - in: query
+ name: language
+ schema:
+ type: string
+ example: en
+ responses:
+ '200':
+ description: List of movies
+ 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/MovieResult'
/request:
get:
summary: Get all requests
diff --git a/package.json b/package.json
index c27830053..64dd419c0 100644
--- a/package.json
+++ b/package.json
@@ -171,7 +171,8 @@
"prepareCmd": "docker build -t sctx/overseerr ."
}
],
- "semantic-release-docker"
+ "semantic-release-docker",
+ "@semantic-release/github"
],
"branches": [
"master"
diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts
index 4a86b68bb..903cd4cc6 100644
--- a/server/api/sonarr.ts
+++ b/server/api/sonarr.ts
@@ -6,7 +6,7 @@ interface SonarrSeason {
monitored: boolean;
}
-interface SonarrSeries {
+export interface SonarrSeries {
title: string;
sortTitle: string;
seasonCount: number;
@@ -33,7 +33,7 @@ interface SonarrSeries {
tvMazeId: number;
firstAired: string;
lastInfoSync?: string;
- seriesType: string;
+ seriesType: 'standard' | 'daily' | 'anime';
cleanTitle: string;
imdbId: string;
titleSlug: string;
@@ -78,6 +78,7 @@ interface AddSeriesOptions {
seasons: number[];
seasonFolder: boolean;
rootFolderPath: string;
+ seriesType: SonarrSeries['seriesType'];
monitored?: boolean;
searchNow?: boolean;
}
@@ -153,6 +154,7 @@ class SonarrAPI {
seasonFolder: options.seasonFolder,
monitored: options.monitored,
rootFolderPath: options.rootFolderPath,
+ seriesType: options.seriesType,
addOptions: {
ignoreEpisodesWithFiles: true,
searchForMissingEpisodes: options.searchNow,
@@ -164,7 +166,7 @@ class SonarrAPI {
} catch (e) {
logger.error('Something went wrong adding a series to Sonarr', {
label: 'Sonarr API',
- message: e.message,
+ errorMessage: e.message,
error: e,
});
throw new Error('Failed to add series');
diff --git a/server/api/themoviedb.ts b/server/api/themoviedb.ts
index 4260cdfc3..6f87823b6 100644
--- a/server/api/themoviedb.ts
+++ b/server/api/themoviedb.ts
@@ -1,5 +1,7 @@
import axios, { AxiosInstance } from 'axios';
+export const ANIME_KEYWORD_ID = 210024;
+
interface SearchOptions {
query: string;
page?: number;
@@ -258,6 +260,11 @@ export interface TmdbTvDetails {
name: string;
origin_country: string;
}[];
+ spoken_languages: {
+ english_name: string;
+ iso_639_1: string;
+ name: string;
+ }[];
seasons: TmdbTvSeasonResult[];
status: string;
type: string;
@@ -268,6 +275,14 @@ export interface TmdbTvDetails {
crew: TmdbCreditCrew[];
};
external_ids: TmdbExternalIds;
+ keywords: {
+ results: TmdbKeyword[];
+ };
+}
+
+export interface TmdbKeyword {
+ id: number;
+ name: string;
}
export interface TmdbPersonDetail {
@@ -437,7 +452,10 @@ class TheMovieDb {
}): Promise => {
try {
const response = await this.axios.get(`/tv/${tvId}`, {
- params: { language, append_to_response: 'credits,external_ids' },
+ params: {
+ language,
+ append_to_response: 'credits,external_ids,keywords',
+ },
});
return response.data;
@@ -524,6 +542,32 @@ class TheMovieDb {
}
}
+ public async getMoviesByKeyword({
+ keywordId,
+ page = 1,
+ language = 'en-US',
+ }: {
+ keywordId: number;
+ page?: number;
+ language?: string;
+ }): Promise {
+ try {
+ const response = await this.axios.get(
+ `/keyword/${keywordId}/movies`,
+ {
+ params: {
+ page,
+ language,
+ },
+ }
+ );
+
+ return response.data;
+ } catch (e) {
+ throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
+ }
+ }
+
public async getTvRecommendations({
tvId,
page = 1,
diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts
index e584dc55c..e5c993679 100644
--- a/server/entity/MediaRequest.ts
+++ b/server/entity/MediaRequest.ts
@@ -15,11 +15,11 @@ import { User } from './User';
import Media from './Media';
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
import { getSettings } from '../lib/settings';
-import TheMovieDb from '../api/themoviedb';
+import TheMovieDb, { ANIME_KEYWORD_ID } from '../api/themoviedb';
import RadarrAPI from '../api/radarr';
import logger from '../logger';
import SeasonRequest from './SeasonRequest';
-import SonarrAPI from '../api/sonarr';
+import SonarrAPI, { SonarrSeries } from '../api/sonarr';
import notificationManager, { Notification } from '../lib/notifications';
@Entity()
@@ -36,10 +36,18 @@ export class MediaRequest {
})
public media: Media;
- @ManyToOne(() => User, (user) => user.requests, { eager: true })
+ @ManyToOne(() => User, (user) => user.requests, {
+ eager: true,
+ onDelete: 'CASCADE',
+ })
public requestedBy: User;
- @ManyToOne(() => User, { nullable: true, cascade: true, eager: true })
+ @ManyToOne(() => User, {
+ nullable: true,
+ cascade: true,
+ eager: true,
+ onDelete: 'SET NULL',
+ })
public modifiedBy?: User;
@CreateDateColumn()
@@ -328,14 +336,32 @@ export class MediaRequest {
throw new Error('Series was missing tvdb id');
}
+ let seriesType: SonarrSeries['seriesType'] = 'standard';
+
+ // Change series type to anime if the anime keyword is present on tmdb
+ if (
+ series.keywords.results.some(
+ (keyword) => keyword.id === ANIME_KEYWORD_ID
+ )
+ ) {
+ seriesType = 'anime';
+ }
+
// Run this asynchronously so we don't wait for it on the UI side
sonarr.addSeries({
- profileId: sonarrSettings.activeProfileId,
- rootFolderPath: sonarrSettings.activeDirectory,
+ profileId:
+ seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
+ ? sonarrSettings.activeAnimeProfileId
+ : sonarrSettings.activeProfileId,
+ rootFolderPath:
+ seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
+ ? sonarrSettings.activeAnimeDirectory
+ : sonarrSettings.activeDirectory,
title: series.name,
tvdbid: series.external_ids.tvdb_id,
seasons: this.seasons.map((season) => season.seasonNumber),
seasonFolder: sonarrSettings.enableSeasonFolders,
+ seriesType,
monitored: true,
searchNow: true,
});
diff --git a/server/index.ts b/server/index.ts
index 657c2bfeb..ef7cd56e0 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -101,11 +101,20 @@ app
);
const port = Number(process.env.PORT) || 3000;
- server.listen(port, () => {
- logger.info(`Server ready on port ${port}`, {
- label: 'Server',
+ const host = process.env.HOST;
+ if (host) {
+ server.listen(port, host, () => {
+ logger.info(`Server ready on ${host} port ${port}`, {
+ label: 'Server',
+ });
});
- });
+ } else {
+ server.listen(port, () => {
+ logger.info(`Server ready on port ${port}`, {
+ label: 'Server',
+ });
+ });
+ }
})
.catch((err) => {
logger.error(err.stack);
diff --git a/server/lib/settings.ts b/server/lib/settings.ts
index f618615cb..327563631 100644
--- a/server/lib/settings.ts
+++ b/server/lib/settings.ts
@@ -213,7 +213,7 @@ class Settings {
}
private generateApiKey(): string {
- return Buffer.from(`${Date.now()}${this.clientId}`).toString('base64');
+ return Buffer.from(`${Date.now()}${uuidv4()})`).toString('base64');
}
/**
diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts
index f541c3d60..946a0b72b 100644
--- a/server/middleware/auth.ts
+++ b/server/middleware/auth.ts
@@ -40,7 +40,7 @@ export const isAuthenticated = (
if (!req.user || !req.user.hasPermission(permissions ?? 0)) {
res.status(403).json({
status: 403,
- error: 'You do not have permisson to access this endpoint',
+ error: 'You do not have permission to access this endpoint',
});
} else {
next();
diff --git a/server/migration/1608217312474-AddUserRequestDeleteCascades.ts b/server/migration/1608217312474-AddUserRequestDeleteCascades.ts
new file mode 100644
index 000000000..ce3de8499
--- /dev/null
+++ b/server/migration/1608217312474-AddUserRequestDeleteCascades.ts
@@ -0,0 +1,32 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddUserRequestDeleteCascades1608219049304
+ implements MigrationInterface {
+ name = 'AddUserRequestDeleteCascades1608219049304';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "media_request"`
+ );
+ await queryRunner.query(`DROP TABLE "media_request"`);
+ await queryRunner.query(
+ `ALTER TABLE "temporary_media_request" RENAME TO "media_request"`
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "media_request" RENAME TO "temporary_media_request"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "temporary_media_request"`
+ );
+ await queryRunner.query(`DROP TABLE "temporary_media_request"`);
+ }
+}
diff --git a/server/models/Tv.ts b/server/models/Tv.ts
index f1e8f7797..7c8759a9b 100644
--- a/server/models/Tv.ts
+++ b/server/models/Tv.ts
@@ -7,6 +7,7 @@ import {
mapCrew,
ExternalIds,
mapExternalIds,
+ Keyword,
} from './common';
import {
TmdbTvEpisodeResult,
@@ -45,6 +46,12 @@ export interface SeasonWithEpisodes extends Season {
externalIds: ExternalIds;
}
+interface SpokenLanguage {
+ englishName: string;
+ iso_639_1: string;
+ name: string;
+}
+
export interface TvDetails {
id: number;
backdropPath?: string;
@@ -74,6 +81,7 @@ export interface TvDetails {
overview: string;
popularity: number;
productionCompanies: ProductionCompany[];
+ spokenLanguages: SpokenLanguage[];
seasons: Season[];
status: string;
type: string;
@@ -84,6 +92,7 @@ export interface TvDetails {
crew: Crew[];
};
externalIds: ExternalIds;
+ keywords: Keyword[];
mediaInfo?: Media;
}
@@ -161,6 +170,11 @@ export const mapTvDetails = (
originCountry: company.origin_country,
logoPath: company.logo_path,
})),
+ spokenLanguages: show.spoken_languages.map((language) => ({
+ englishName: language.english_name,
+ iso_639_1: language.iso_639_1,
+ name: language.name,
+ })),
seasons: show.seasons.map(mapSeasonResult),
status: show.status,
type: show.type,
@@ -179,5 +193,9 @@ export const mapTvDetails = (
crew: show.credits.crew.map(mapCrew),
},
externalIds: mapExternalIds(show.external_ids),
+ keywords: show.keywords.results.map((keyword) => ({
+ id: keyword.id,
+ name: keyword.name,
+ })),
mediaInfo: media,
});
diff --git a/server/models/common.ts b/server/models/common.ts
index 696f7df23..90945dc2e 100644
--- a/server/models/common.ts
+++ b/server/models/common.ts
@@ -11,6 +11,11 @@ export interface ProductionCompany {
name: string;
}
+export interface Keyword {
+ id: number;
+ name: string;
+}
+
export interface Genre {
id: number;
name: string;
diff --git a/server/routes/auth.ts b/server/routes/auth.ts
index 50a5f2201..c734e7020 100644
--- a/server/routes/auth.ts
+++ b/server/routes/auth.ts
@@ -14,7 +14,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
return res.status(500).json({
status: 500,
error:
- 'Requsted user endpoint withuot valid authenticated user in session',
+ 'Requested user endpoint without valid authenticated user in session',
});
}
const user = await userRepository.findOneOrFail({
diff --git a/server/routes/discover.ts b/server/routes/discover.ts
index e6c9de45b..1193354ce 100644
--- a/server/routes/discover.ts
+++ b/server/routes/discover.ts
@@ -121,4 +121,36 @@ discoverRoutes.get('/trending', async (req, res) => {
});
});
+discoverRoutes.get<{ keywordId: string }>(
+ '/keyword/:keywordId/movies',
+ async (req, res) => {
+ const tmdb = new TheMovieDb();
+
+ const data = await tmdb.getMoviesByKeyword({
+ keywordId: Number(req.params.keywordId),
+ page: Number(req.query.page),
+ language: req.query.language as string,
+ });
+
+ 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) =>
+ mapMovieResult(
+ result,
+ media.find(
+ (req) =>
+ req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
+ )
+ )
+ ),
+ });
+ }
+);
+
export default discoverRoutes;
diff --git a/server/routes/settings.ts b/server/routes/settings.ts
index 25a702b07..f90ae9b62 100644
--- a/server/routes/settings.ts
+++ b/server/routes/settings.ts
@@ -23,16 +23,27 @@ import { getAppVersion } from '../utils/appVersion';
const settingsRoutes = Router();
-settingsRoutes.get('/main', (req, res) => {
+const filteredMainSettings = (
+ user: User,
+ main: MainSettings
+): Partial => {
+ if (!user?.hasPermission(Permission.ADMIN)) {
+ return {
+ applicationUrl: main.applicationUrl,
+ };
+ }
+
+ return main;
+};
+
+settingsRoutes.get('/main', (req, res, next) => {
const settings = getSettings();
- if (!req.user?.hasPermission(Permission.ADMIN)) {
- return res.status(200).json({
- applicationUrl: settings.main.applicationUrl,
- } as Partial);
+ if (!req.user) {
+ return next({ status: 500, message: 'User missing from request' });
}
- res.status(200).json(settings.main);
+ res.status(200).json(filteredMainSettings(req.user, settings.main));
});
settingsRoutes.post('/main', (req, res) => {
@@ -44,6 +55,18 @@ settingsRoutes.post('/main', (req, res) => {
return res.status(200).json(settings.main);
});
+settingsRoutes.get('/main/regenerate', (req, res, next) => {
+ const settings = getSettings();
+
+ const main = settings.regenerateApiKey();
+
+ if (!req.user) {
+ return next({ status: 500, message: 'User missing from request' });
+ }
+
+ return res.status(200).json(filteredMainSettings(req.user, main));
+});
+
settingsRoutes.get('/plex', (_req, res) => {
const settings = getSettings();
diff --git a/server/routes/tv.ts b/server/routes/tv.ts
index 9f9201e41..7e8b06257 100644
--- a/server/routes/tv.ts
+++ b/server/routes/tv.ts
@@ -4,6 +4,7 @@ import { mapTvDetails, mapSeasonWithEpisodes } from '../models/Tv';
import { mapTvResult } from '../models/Search';
import Media from '../entity/Media';
import RottenTomatoes from '../api/rottentomatoes';
+import logger from '../logger';
const tvRoutes = Router();
@@ -19,6 +20,10 @@ tvRoutes.get('/:id', async (req, res, next) => {
return res.status(200).json(mapTvDetails(tv, media));
} catch (e) {
+ logger.error('Failed to get tv show', {
+ label: 'API',
+ errorMessage: e.message,
+ });
return next({ status: 404, message: 'TV Show does not exist' });
}
});
diff --git a/server/routes/user.ts b/server/routes/user.ts
index 60753e9be..e6dd136a5 100644
--- a/server/routes/user.ts
+++ b/server/routes/user.ts
@@ -1,7 +1,9 @@
import { Router } from 'express';
import { getRepository } from 'typeorm';
+import { MediaRequest } from '../entity/MediaRequest';
import { User } from '../entity/User';
import { hasPermission, Permission } from '../lib/permissions';
+import logger from '../logger';
const router = Router();
@@ -94,13 +96,49 @@ router.delete<{ id: string }>('/:id', async (req, res, next) => {
try {
const userRepository = getRepository(User);
- const user = await userRepository.findOneOrFail({
+ const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
+ relations: ['requests'],
});
+
+ if (!user) {
+ return next({ status: 404, message: 'User not found' });
+ }
+
+ if (user.id === 1) {
+ return next({ status: 405, message: 'This account cannot be deleted.' });
+ }
+
+ if (user.hasPermission(Permission.ADMIN)) {
+ return next({
+ status: 405,
+ message: 'You cannot delete users with administrative privileges.',
+ });
+ }
+
+ const requestRepository = getRepository(MediaRequest);
+
+ /**
+ * Requests are usually deleted through a cascade constraint. Those however, do
+ * not trigger the removal event so listeners to not run and the parent Media
+ * will not be updated back to unknown for titles that were still pending. So
+ * we manually remove all requests from the user here so the parent media's
+ * properly reflect the change.
+ */
+ await requestRepository.remove(user.requests);
+
await userRepository.delete(user.id);
return res.status(200).json(user.filter());
} catch (e) {
- next({ status: 404, message: 'User not found' });
+ logger.error('Something went wrong deleting a user', {
+ label: 'API',
+ userId: req.params.id,
+ errorMessage: e.message,
+ });
+ return next({
+ status: 500,
+ message: 'Something went wrong deleting the user',
+ });
}
});
diff --git a/src/components/Common/Modal/index.tsx b/src/components/Common/Modal/index.tsx
index beb8ff8ee..9420b85ea 100644
--- a/src/components/Common/Modal/index.tsx
+++ b/src/components/Common/Modal/index.tsx
@@ -8,7 +8,7 @@ import { useIntl } from 'react-intl';
import globalMessages from '../../../i18n/globalMessages';
import Transition from '../../Transition';
-interface ModalProps extends React.HTMLAttributes {
+interface ModalProps {
title?: string;
onCancel?: (e?: MouseEvent) => void;
onOk?: (e?: MouseEvent) => void;
diff --git a/src/components/Discover/Holiday/index.tsx b/src/components/Discover/Holiday/index.tsx
new file mode 100644
index 000000000..319101946
--- /dev/null
+++ b/src/components/Discover/Holiday/index.tsx
@@ -0,0 +1,70 @@
+import React, { useContext } from 'react';
+import { useSWRInfinite } from 'swr';
+import type { MovieResult } from '../../../../server/models/Search';
+import ListView from '../../Common/ListView';
+import { LanguageContext } from '../../../context/LanguageContext';
+import Header from '../../Common/Header';
+
+interface SearchResult {
+ page: number;
+ totalResults: number;
+ totalPages: number;
+ results: MovieResult[];
+}
+
+const Holiday: React.FC = () => {
+ 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/keyword/207317/movies?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}
;
+ }
+
+ const titles = data?.reduce(
+ (a, v) => [...a, ...v.results],
+ [] as MovieResult[]
+ );
+
+ const isEmpty = !isLoadingInitialData && titles?.length === 0;
+ const isReachingEnd =
+ isEmpty || (data && data[data.length - 1]?.results.length < 20);
+
+ return (
+ <>
+
+ 0)
+ }
+ isReachingEnd={isReachingEnd}
+ onScrollBottom={fetchMore}
+ />
+ >
+ );
+};
+
+export default Holiday;
diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx
index 895237c74..7790150f9 100644
--- a/src/components/Discover/index.tsx
+++ b/src/components/Discover/index.tsx
@@ -63,6 +63,12 @@ const Discover: React.FC = () => {
} = useSWR(
`/api/v1/discover/movies/upcoming?language=${locale}`
);
+ const {
+ data: holUpcomingData,
+ error: holUpcomingError,
+ } = useSWR(
+ `/api/v1/discover/keyword/207317/movies?language=${locale}`
+ );
const { data: trendingData, error: trendingError } = useSWR(
`/api/v1/discover/trending?language=${locale}`
@@ -140,6 +146,57 @@ const Discover: React.FC = () => {
placeholder={ }
emptyMessage={intl.formatMessage(messages.nopending)}
/>
+ {/* Special Temporary Slider */}
+
+ (
+
+ ))}
+ />
+ {/* End Special Temporary Slider */}
diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx
index 799a32579..5d4b36c7f 100644
--- a/src/components/Layout/index.tsx
+++ b/src/components/Layout/index.tsx
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
import SearchInput from './SearchInput';
import UserDropdown from './UserDropdown';
import Sidebar from './Sidebar';
-import Notifications from './Notifications';
import LanguagePicker from './LanguagePicker';
import { useRouter } from 'next/router';
import { defineMessages, FormattedMessage } from 'react-intl';
@@ -47,7 +46,6 @@ const Layout: React.FC = ({ children }) => {
-
diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx
index 87095b7bf..56dd81e12 100644
--- a/src/components/Login/index.tsx
+++ b/src/components/Login/index.tsx
@@ -20,7 +20,7 @@ const Login: React.FC = () => {
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
// We take the token and attempt to login. If we get a success message, we will
- // ask swr to revalidate the user which _shouid_ come back with a valid user.
+ // ask swr to revalidate the user which _should_ come back with a valid user.
useEffect(() => {
const login = async () => {
setProcessing(true);
diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx
index df99de949..f11fb2b7a 100644
--- a/src/components/MovieDetails/index.tsx
+++ b/src/components/MovieDetails/index.tsx
@@ -65,6 +65,7 @@ const messages = defineMessages({
'This will remove all media data including all requests for this item. This action is irreversible. If this item exists in your Plex library, the media information will be recreated next sync.',
approve: 'Approve',
decline: 'Decline',
+ studio: 'Studio',
});
interface MovieDetailsProps {
@@ -484,14 +485,32 @@ const MovieDetails: React.FC
= ({ movie }) => {
)}
-
-
-
-
-
- {data.originalLanguage}
-
-
+ {data.spokenLanguages.some(
+ (lng) => lng.iso_639_1 === data.originalLanguage
+ ) && (
+
+
+
+
+
+ {
+ data.spokenLanguages.find(
+ (lng) => lng.iso_639_1 === data.originalLanguage
+ )?.name
+ }
+
+
+ )}
+ {data.productionCompanies[0] && (
+
+
+
+
+
+ {data.productionCompanies[0]?.name}
+
+
+ )}
diff --git a/src/components/Settings/CopyButton.tsx b/src/components/Settings/CopyButton.tsx
index 717667d59..0ddf4dbfb 100644
--- a/src/components/Settings/CopyButton.tsx
+++ b/src/components/Settings/CopyButton.tsx
@@ -25,7 +25,10 @@ const CopyButton: React.FC<{ textToCopy: string }> = ({ textToCopy }) => {
return (
{
+ e.preventDefault();
+ setCopied();
+ }}
className="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-500 text-sm leading-5 font-medium text-white bg-indigo-500 hover:bg-indigo-400 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
>
{
+ const { addToast } = useToasts();
const { hasPermission } = useUser();
const intl = useIntl();
const { data, error, revalidate } = useSWR(
'/api/v1/settings/main'
);
+ const regenerate = async () => {
+ try {
+ await axios.get('/api/v1/settings/main/regenerate');
+
+ revalidate();
+ addToast(intl.formatMessage(messages.toastApiKeySuccess), {
+ autoDismiss: true,
+ appearance: 'success',
+ });
+ } catch (e) {
+ addToast(intl.formatMessage(messages.toastApiKeyFailure), {
+ autoDismiss: true,
+ appearance: 'error',
+ });
+ }
+ };
+
if (!data && !error) {
return ;
}
@@ -50,8 +73,16 @@ const SettingsMain: React.FC = () => {
await axios.post('/api/v1/settings/main', {
applicationUrl: values.applicationUrl,
});
+
+ addToast(intl.formatMessage(messages.toastSettingsSuccess), {
+ autoDismiss: true,
+ appearance: 'success',
+ });
} catch (e) {
- // TODO show error
+ addToast(intl.formatMessage(messages.toastSettingsFailure), {
+ autoDismiss: true,
+ appearance: 'error',
+ });
} finally {
revalidate();
}
@@ -77,8 +108,17 @@ const SettingsMain: React.FC = () => {
value={data?.apiKey}
readOnly
/>
-
-
+
+ {
+ e.preventDefault();
+ regenerate();
+ }}
+ className="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-500 text-sm leading-5 font-medium rounded-r-md text-white bg-indigo-500 hover:bg-indigo-400 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
+ >
= ({
baseUrl: sonarr?.baseUrl,
activeProfileId: sonarr?.activeProfileId,
rootFolder: sonarr?.activeDirectory,
+ activeAnimeProfileId: sonarr?.activeAnimeProfileId,
+ activeAnimeRootFolder: sonarr?.activeAnimeDirectory,
isDefault: sonarr?.isDefault ?? false,
is4k: sonarr?.is4k ?? false,
enableSeasonFolders: sonarr?.enableSeasonFolders ?? false,
@@ -192,6 +196,9 @@ const SonarrModal: React.FC = ({
const profileName = testResponse.profiles.find(
(profile) => profile.id === Number(values.activeProfileId)
)?.name;
+ const animeProfileName = testResponse.profiles.find(
+ (profile) => profile.id === Number(values.activeAnimeProfileId)
+ )?.name;
const submission = {
name: values.name,
@@ -203,6 +210,11 @@ const SonarrModal: React.FC = ({
activeProfileId: Number(values.activeProfileId),
activeProfileName: profileName,
activeDirectory: values.rootFolder,
+ activeAnimeProfileId: values.activeAnimeProfileId
+ ? Number(values.activeAnimeProfileId)
+ : undefined,
+ activeAnimeProfileName: animeProfileName ?? undefined,
+ activeAnimeDirectory: values.activeAnimeRootFolder,
is4k: values.is4k,
isDefault: values.isDefault,
enableSeasonFolders: values.enableSeasonFolders,
@@ -528,6 +540,92 @@ const SonarrModal: React.FC = ({
)}
+
+
+ {intl.formatMessage(messages.animequalityprofile)}
+
+
+
+
+
+ {isTesting
+ ? intl.formatMessage(messages.loadingprofiles)
+ : !isValidated
+ ? intl.formatMessage(
+ messages.testFirstQualityProfiles
+ )
+ : intl.formatMessage(messages.selectQualityProfile)}
+
+ {testResponse.profiles.length > 0 &&
+ testResponse.profiles.map((profile) => (
+
+ {profile.name}
+
+ ))}
+
+
+ {errors.activeAnimeProfileId &&
+ touched.activeAnimeProfileId && (
+
+ {errors.activeAnimeProfileId}
+
+ )}
+
+
+
+
+ {intl.formatMessage(messages.animerootfolder)}
+
+
+
+
+
+ {isTesting
+ ? intl.formatMessage(messages.loadingrootfolders)
+ : !isValidated
+ ? intl.formatMessage(messages.testFirstRootFolders)
+ : intl.formatMessage(messages.selectRootFolder)}
+
+ {testResponse.rootFolders.length > 0 &&
+ testResponse.rootFolders.map((folder) => (
+
+ {folder.path}
+
+ ))}
+
+
+ {errors.activeAnimeRootFolder &&
+ touched.activeAnimeRootFolder && (
+
+ {errors.rootFolder}
+
+ )}
+
+
= ({ tv }) => {
)}
)}
+ {data.keywords.some(
+ (keyword) => keyword.id === ANIME_KEYWORD_ID
+ ) && (
+
+
+ {intl.formatMessage(messages.showtype)}
+
+
+ {intl.formatMessage(messages.anime)}
+
+
+ )}
@@ -439,14 +455,32 @@ const TvDetails: React.FC = ({ tv }) => {
{data.status}
-
-
-
-
-
- {data.originalLanguage}
-
-
+ {data.spokenLanguages.some(
+ (lng) => lng.iso_639_1 === data.originalLanguage
+ ) && (
+
+
+
+
+
+ {
+ data.spokenLanguages.find(
+ (lng) => lng.iso_639_1 === data.originalLanguage
+ )?.name
+ }
+
+
+ )}
+ {data.networks.length > 0 && (
+
+
+
+
+
+ {data.networks.map((n) => n.name).join(', ')}
+
+
+ )}
diff --git a/src/components/UserEdit/index.tsx b/src/components/UserEdit/index.tsx
index 36b05a696..1df498d77 100644
--- a/src/components/UserEdit/index.tsx
+++ b/src/components/UserEdit/index.tsx
@@ -23,13 +23,12 @@ const messages = defineMessages({
'Grants permission to manage Overseerr users. Users with this permission cannot modify users with Administrator privilege, or grant it.',
settings: 'Manage Settings',
settingsDescription:
- 'Grants permission to modify all Overseerr settings. User must have this permission to be able to grant it to others.',
+ 'Grants permission to modify all Overseerr settings. A user must have this permission to grant it to others.',
managerequests: 'Manage Requests',
managerequestsDescription:
'Grants permission to manage Overseerr requests. This includes approving and denying requests.',
request: 'Request',
- requestDescription:
- 'Grants permission to make requests for movies or tv shows.',
+ requestDescription: 'Grants permission to request movies and series.',
vote: 'Vote',
voteDescription:
'Grants permission to vote on requests (voting not yet implemented)',
@@ -38,7 +37,7 @@ const messages = defineMessages({
'Grants auto approval for any requests made by this user.',
save: 'Save',
saving: 'Saving...',
- usersaved: 'User succesfully saved',
+ usersaved: 'User saved',
userfail: 'Something went wrong saving the user.',
});
diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx
index e6b8135dd..37b8752dd 100644
--- a/src/components/UserList/index.tsx
+++ b/src/components/UserList/index.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState } from 'react';
import useSWR from 'swr';
import LoadingSpinner from '../Common/LoadingSpinner';
import type { User } from '../../../server/entity/User';
@@ -10,6 +10,11 @@ import { Permission } from '../../hooks/useUser';
import { useRouter } from 'next/router';
import Header from '../Common/Header';
import Table from '../Common/Table';
+import Transition from '../Transition';
+import Modal from '../Common/Modal';
+import axios from 'axios';
+import { useToasts } from 'react-toast-notifications';
+import globalMessages from '../../i18n/globalMessages';
const messages = defineMessages({
userlist: 'User List',
@@ -24,12 +29,46 @@ const messages = defineMessages({
admin: 'Admin',
user: 'User',
plexuser: 'Plex User',
+ deleteuser: 'Delete User',
+ userdeleted: 'User deleted',
+ userdeleteerror: 'Something went wrong deleting the user',
+ deleteconfirm:
+ 'Are you sure you want to delete this user? All existing request data from this user will be removed.',
});
const UserList: React.FC = () => {
const intl = useIntl();
const router = useRouter();
- const { data, error } = useSWR('/api/v1/user');
+ const { addToast } = useToasts();
+ const { data, error, revalidate } = useSWR('/api/v1/user');
+ const [isDeleting, setDeleting] = useState(false);
+ const [deleteModal, setDeleteModal] = useState<{
+ isOpen: boolean;
+ user?: User;
+ }>({
+ isOpen: false,
+ });
+
+ const deleteUser = async () => {
+ setDeleting(true);
+
+ try {
+ await axios.delete(`/api/v1/user/${deleteModal.user?.id}`);
+
+ addToast(intl.formatMessage(messages.userdeleted), {
+ autoDismiss: true,
+ appearance: 'success',
+ });
+ setDeleteModal({ isOpen: false });
+ } catch (e) {
+ addToast(intl.formatMessage(messages.userdeleteerror), {
+ autoDismiss: true,
+ appearance: 'error',
+ });
+ } finally {
+ revalidate();
+ }
+ };
if (!data && !error) {
return ;
@@ -37,6 +76,46 @@ const UserList: React.FC = () => {
return (
<>
+
+ deleteUser()}
+ okText={
+ isDeleting
+ ? intl.formatMessage(globalMessages.deleting)
+ : intl.formatMessage(globalMessages.delete)
+ }
+ okDisabled={isDeleting}
+ okButtonType="danger"
+ onCancel={() => setDeleteModal({ isOpen: false })}
+ title={intl.formatMessage(messages.deleteuser)}
+ iconSvg={
+
+
+
+ }
+ >
+ {intl.formatMessage(messages.deleteconfirm)}
+
+
{intl.formatMessage(messages.userlist)}
@@ -104,7 +183,11 @@ const UserList: React.FC = () => {
>
{intl.formatMessage(messages.edit)}
-
+ setDeleteModal({ isOpen: true, user })}
+ >
{intl.formatMessage(messages.delete)}
diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts
index 448b2d9cb..16b8cc011 100644
--- a/src/i18n/globalMessages.ts
+++ b/src/i18n/globalMessages.ts
@@ -14,6 +14,7 @@ const globalMessages = defineMessages({
approve: 'Approve',
decline: 'Decline',
delete: 'Delete',
+ deleting: 'Deleting…',
});
export default globalMessages;
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json
index f08ebcd5e..cce17582e 100644
--- a/src/i18n/locale/en.json
+++ b/src/i18n/locale/en.json
@@ -43,6 +43,7 @@
"components.MovieDetails.similar": "Similar Titles",
"components.MovieDetails.similarsubtext": "Other movies similar to {title}",
"components.MovieDetails.status": "Status",
+ "components.MovieDetails.studio": "Studio",
"components.MovieDetails.unavailable": "Unavailable",
"components.MovieDetails.userrating": "User Rating",
"components.MovieDetails.viewrequest": "View Request",
@@ -152,6 +153,8 @@
"components.Settings.SettingsAbout.totalrequests": "Total Requests",
"components.Settings.SettingsAbout.version": "Version",
"components.Settings.SonarrModal.add": "Add Server",
+ "components.Settings.SonarrModal.animequalityprofile": "Anime Quality Profile",
+ "components.Settings.SonarrModal.animerootfolder": "Anime Root Folder",
"components.Settings.SonarrModal.apiKey": "API Key",
"components.Settings.SonarrModal.apiKeyPlaceholder": "Your Sonarr API Key",
"components.Settings.SonarrModal.baseUrl": "Base URL",
@@ -239,6 +242,10 @@
"components.Settings.startscan": "Start Scan",
"components.Settings.sync": "Sync Plex Libraries",
"components.Settings.syncing": "Syncing…",
+ "components.Settings.toastApiKeyFailure": "Something went wrong generating a new API Key.",
+ "components.Settings.toastApiKeySuccess": "New API Key generated!",
+ "components.Settings.toastSettingsFailure": "Something went wrong saving settings.",
+ "components.Settings.toastSettingsSuccess": "Settings saved.",
"components.Settings.validationHostnameRequired": "You must provide a hostname/IP",
"components.Settings.validationPortRequired": "You must provide a port",
"components.Setup.configureplex": "Configure Plex",
@@ -255,6 +262,7 @@
"components.TitleCard.movie": "Movie",
"components.TitleCard.tvshow": "Series",
"components.TvDetails.TvCast.fullseriescast": "Full Series Cast",
+ "components.TvDetails.anime": "Anime",
"components.TvDetails.approve": "Approve",
"components.TvDetails.approverequests": "Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}",
"components.TvDetails.available": "Available",
@@ -267,6 +275,7 @@
"components.TvDetails.manageModalNoRequests": "No Requests",
"components.TvDetails.manageModalRequests": "Requests",
"components.TvDetails.manageModalTitle": "Manage Series",
+ "components.TvDetails.network": "Network",
"components.TvDetails.originallanguage": "Original Language",
"components.TvDetails.overview": "Overview",
"components.TvDetails.overviewunavailable": "Overview unavailable",
@@ -275,6 +284,7 @@
"components.TvDetails.recommendationssubtext": "If you liked {title}, you might also like...",
"components.TvDetails.request": "Request",
"components.TvDetails.requestmore": "Request More",
+ "components.TvDetails.showtype": "Show Type",
"components.TvDetails.similar": "Similar Series",
"components.TvDetails.similarsubtext": "Other series similar to {title}",
"components.TvDetails.status": "Status",
@@ -306,12 +316,16 @@
"components.UserList.admin": "Admin",
"components.UserList.created": "Created",
"components.UserList.delete": "Delete",
+ "components.UserList.deleteconfirm": "Are you sure you want to delete this user? All existing request data from this user will be removed.",
+ "components.UserList.deleteuser": "Delete User",
"components.UserList.edit": "Edit",
"components.UserList.lastupdated": "Last Updated",
"components.UserList.plexuser": "Plex User",
"components.UserList.role": "Role",
"components.UserList.totalrequests": "Total Requests",
"components.UserList.user": "User",
+ "components.UserList.userdeleted": "User deleted",
+ "components.UserList.userdeleteerror": "Something went wrong deleting the user",
"components.UserList.userlist": "User List",
"components.UserList.username": "Username",
"components.UserList.usertype": "User Type",
@@ -322,6 +336,7 @@
"i18n.decline": "Decline",
"i18n.declined": "Declined",
"i18n.delete": "Delete",
+ "i18n.deleting": "Deleting…",
"i18n.movies": "Movies",
"i18n.partiallyavailable": "Partially Available",
"i18n.pending": "Pending",
diff --git a/src/pages/discover/holiday.tsx b/src/pages/discover/holiday.tsx
new file mode 100644
index 000000000..3123acb80
--- /dev/null
+++ b/src/pages/discover/holiday.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import { NextPage } from 'next';
+import Holiday from '../../components/Discover/Holiday';
+
+const HolidayPage: NextPage = () => {
+ return ;
+};
+
+export default HolidayPage;
diff --git a/src/utils/typeHelpers.ts b/src/utils/typeHelpers.ts
index 4d5cc1242..1ec82c427 100644
--- a/src/utils/typeHelpers.ts
+++ b/src/utils/typeHelpers.ts
@@ -3,7 +3,7 @@ export type Nullable = T | null;
export type Maybe = T | null | undefined;
/**
- * Helps type objects with an abitrary number of properties that are
+ * Helps type objects with an arbitrary number of properties that are
* usually being defined at export.
*
* @param component Main object you want to apply properties to