diff --git a/.all-contributorsrc b/.all-contributorsrc
index 378c8ee2..664f5658 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 8d401771..03a03c2f 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 c98d3174..647cdeca 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 03d6e527..ef1ca9d1 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 90c2bcb7..d84ac3fa 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 c2783005..64dd419c 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 4a86b68b..903cd4cc 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 4260cdfc..6f87823b 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 e584dc55..e5c99367 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 657c2bfe..ef7cd56e 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 f618615c..32756363 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 f541c3d6..946a0b72 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 00000000..ce3de849
--- /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 f1e8f779..7c8759a9 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 696f7df2..90945dc2 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 50a5f220..c734e702 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 e6c9de45..1193354c 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 25a702b0..f90ae9b6 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 settings = getSettings();
-
- if (!req.user?.hasPermission(Permission.ADMIN)) {
- return res.status(200).json({
- applicationUrl: settings.main.applicationUrl,
- } as Partial);
+const filteredMainSettings = (
+ user: User,
+ main: MainSettings
+): Partial => {
+ if (!user?.hasPermission(Permission.ADMIN)) {
+ return {
+ applicationUrl: main.applicationUrl,
+ };
}
- res.status(200).json(settings.main);
+ return main;
+};
+
+settingsRoutes.get('/main', (req, res, next) => {
+ const settings = getSettings();
+
+ if (!req.user) {
+ return next({ status: 500, message: 'User missing from request' });
+ }
+
+ 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 9f9201e4..7e8b0625 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 60753e9b..e6dd136a 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 beb8ff8e..9420b85e 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 00000000..31910194
--- /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 895237c7..7790150f 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 799a3257..5d4b36c7 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 87095b7b..56dd81e1 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 df99de94..f11fb2b7 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 717667d5..0ddf4dbf 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 36b05a69..1df498d7 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 e6b8135d..37b8752d 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 448b2d9c..16b8cc01 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 f08ebcd5..cce17582 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 00000000..3123acb8
--- /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 4d5cc124..1ec82c42 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