From 727fa06c18febb2a97ca219cc6bf0277ff462acd Mon Sep 17 00:00:00 2001 From: sct Date: Thu, 17 Dec 2020 16:03:27 +0000 Subject: [PATCH 01/14] feat(frontend): added user deletion to the user list also includes small updates to the api to prevent administrators from being deleted, as well as migrations to cascade deletions to requests the users made fixes #348 --- server/entity/MediaRequest.ts | 12 ++- ...8217312474-AddUserRequestDeleteCascades.ts | 32 +++++++ server/routes/user.ts | 42 ++++++++- src/components/Common/Modal/index.tsx | 2 +- src/components/UserList/index.tsx | 89 ++++++++++++++++++- src/i18n/globalMessages.ts | 1 + src/i18n/locale/en.json | 5 ++ 7 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 server/migration/1608217312474-AddUserRequestDeleteCascades.ts diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index e584dc55..d8ae9c89 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -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() 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/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/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)} - 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..79276bfa 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -306,12 +306,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 +326,7 @@ "i18n.decline": "Decline", "i18n.declined": "Declined", "i18n.delete": "Delete", + "i18n.deleting": "Deleting…", "i18n.movies": "Movies", "i18n.partiallyavailable": "Partially Available", "i18n.pending": "Pending", From df4ef1fbe54618dce6632683c5e16433909653fe Mon Sep 17 00:00:00 2001 From: samwiseg0 <2241731+samwiseg0@users.noreply.github.com> Date: Thu, 17 Dec 2020 18:06:14 -0500 Subject: [PATCH 02/14] docs: Remove the issue lock as it will not allow users to edit [skip ci] (#376) --- .github/workflows/invalid_template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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' From 086183b5636aa8d075d01fe59492c3eab0d1345b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C4=81rti=C5=86=C5=A1=20Mo=C5=BEeiko?= Date: Thu, 17 Dec 2020 18:11:20 -0800 Subject: [PATCH 03/14] feat: allow to listen server on specific host interface (#381) fix #273 --- server/index.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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); From 1f0486eba226ba71e7b4fcfa63608a820851d3d9 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 18 Dec 2020 11:12:30 +0900 Subject: [PATCH 04/14] docs: add mmozeiko as a contributor (#382) [skip ci] * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) 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": "\"All-orange.svg\"/>", diff --git a/README.md b/README.md index 03d6e527..ef1ca9d1 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Language grade: JavaScript GitHub -All Contributors +All Contributors

@@ -118,6 +118,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
+

ecelebi29

💻 📖

Mārtiņš Možeiko

💻
From 0972f40a4e1fb3b5f02b07ae46b997d71aab9bfb Mon Sep 17 00:00:00 2001 From: sct Date: Fri, 18 Dec 2020 14:32:40 +0900 Subject: [PATCH 05/14] feat: anime profile support (#384) closes #266 --- overseerr-api.yml | 37 +++++-- server/api/sonarr.ts | 8 +- server/api/themoviedb.ts | 20 +++- server/entity/MediaRequest.ts | 26 ++++- server/models/Tv.ts | 18 ++++ server/models/common.ts | 5 + server/routes/tv.ts | 5 + src/components/Settings/SonarrModal/index.tsx | 98 +++++++++++++++++++ src/components/TvDetails/index.tsx | 15 +++ src/i18n/locale/en.json | 4 + 10 files changed, 222 insertions(+), 14 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index 90c2bcb7..3fcafe56 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 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..d6e5cc78 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; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index d8ae9c89..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() @@ -336,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/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/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/src/components/Settings/SonarrModal/index.tsx b/src/components/Settings/SonarrModal/index.tsx index ad6fee59..cf1bb3ab 100644 --- a/src/components/Settings/SonarrModal/index.tsx +++ b/src/components/Settings/SonarrModal/index.tsx @@ -36,6 +36,8 @@ const messages = defineMessages({ baseUrlPlaceholder: 'Example: /sonarr', qualityprofile: 'Quality Profile', rootfolder: 'Root Folder', + animequalityprofile: 'Anime Quality Profile', + animerootfolder: 'Anime Root Folder', seasonfolders: 'Season Folders', server4k: '4K Server', selectQualityProfile: 'Select a Quality Profile', @@ -182,6 +184,8 @@ const SonarrModal: React.FC = ({ 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 = ({ )} +
+ +
+
+ + + {testResponse.profiles.length > 0 && + testResponse.profiles.map((profile) => ( + + ))} + +
+ {errors.activeAnimeProfileId && + touched.activeAnimeProfileId && ( +
+ {errors.activeAnimeProfileId} +
+ )} +
+
+
+ +
+
+ + + {testResponse.rootFolders.length > 0 && + testResponse.rootFolders.map((folder) => ( + + ))} + +
+ {errors.activeAnimeRootFolder && + touched.activeAnimeRootFolder && ( +
+ {errors.rootFolder} +
+ )} +
+
)} + {data.keywords.some( + (keyword) => keyword.id === ANIME_KEYWORD_ID + ) && ( +
+ + {intl.formatMessage(messages.showtype)} + + + {intl.formatMessage(messages.anime)} + +
+ )}
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 79276bfa..475b008a 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -152,6 +152,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", @@ -255,6 +257,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", @@ -275,6 +278,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", From f88c4a6d4a49f8f3451ba6c85153677f33b7f5f6 Mon Sep 17 00:00:00 2001 From: sct Date: Fri, 18 Dec 2020 06:13:44 +0000 Subject: [PATCH 06/14] feat(api): add movie keyword search --- overseerr-api.yml | 45 +++++++++++++++++++++++++++++++++++++++ server/api/themoviedb.ts | 26 ++++++++++++++++++++++ server/routes/discover.ts | 32 ++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/overseerr-api.yml b/overseerr-api.yml index 3fcafe56..752a4fe7 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1895,6 +1895,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/server/api/themoviedb.ts b/server/api/themoviedb.ts index d6e5cc78..6f87823b 100644 --- a/server/api/themoviedb.ts +++ b/server/api/themoviedb.ts @@ -542,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/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; From 908f63557ca03a1da8b16809ffa2c3acd782d94e Mon Sep 17 00:00:00 2001 From: sct Date: Fri, 18 Dec 2020 06:14:41 +0000 Subject: [PATCH 07/14] feat(holiday): special seasonal slider added to discover :) --- src/components/Discover/Holiday/index.tsx | 70 +++++++++++++++++++++++ src/components/Discover/index.tsx | 57 ++++++++++++++++++ src/pages/discover/holiday.tsx | 9 +++ 3 files changed, 136 insertions(+) create mode 100644 src/components/Discover/Holiday/index.tsx create mode 100644 src/pages/discover/holiday.tsx 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 ( + <> +
Happy Holidays!
+ 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/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; From baa62b286d20c131217b985a18be6009b5d98b73 Mon Sep 17 00:00:00 2001 From: sct Date: Fri, 18 Dec 2020 07:04:17 +0000 Subject: [PATCH 08/14] ci: push develop images to ghcr.io --- .github/workflows/ci.yml | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d401771..ab012a84 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/sctx/overseerr:develop + ghcr.io/sctx/overseerr:${{ github.sha }} From e75b592bb200426aaa0b882e224ad69d4d9be281 Mon Sep 17 00:00:00 2001 From: sct Date: Fri, 18 Dec 2020 07:29:23 +0000 Subject: [PATCH 09/14] ci: fix image name for ghcr.io tags --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab012a84..03a03c2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,5 +55,5 @@ jobs: tags: | sctx/overseerr:develop sctx/overseerr:${{ github.sha }} - ghcr.io/sctx/overseerr:develop - ghcr.io/sctx/overseerr:${{ github.sha }} + ghcr.io/sct/overseerr:develop + ghcr.io/sct/overseerr:${{ github.sha }} From 992a23222a186c4c207a92a62668a6219b299eb7 Mon Sep 17 00:00:00 2001 From: sct Date: Fri, 18 Dec 2020 18:37:52 +0900 Subject: [PATCH 10/14] ci: add @semantic-release/github to release plugins --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index f8926e9c..f6f3d9b3 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" From 4b6ad8a3871957db4192b603abf38404250cea5d Mon Sep 17 00:00:00 2001 From: sct Date: Fri, 18 Dec 2020 19:01:56 +0900 Subject: [PATCH 11/14] feat(frontend): add studio/networks to movie/tv details closes #370 --- src/components/MovieDetails/index.tsx | 35 +++++++++++++++++++++------ src/components/TvDetails/index.tsx | 35 +++++++++++++++++++++------ src/i18n/locale/en.json | 2 ++ 3 files changed, 56 insertions(+), 16 deletions(-) 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/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 1eaada61..d0f8d884 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -59,6 +59,7 @@ const messages = defineMessages({ decline: 'Decline', showtype: 'Show Type', anime: 'Anime', + network: 'Network', }); interface TvDetailsProps { @@ -454,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/i18n/locale/en.json b/src/i18n/locale/en.json index 475b008a..b43fcd8a 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", @@ -270,6 +271,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", From 6933b661ca4d63f18468b976aaad43c695fad136 Mon Sep 17 00:00:00 2001 From: sct Date: Fri, 18 Dec 2020 19:11:39 +0900 Subject: [PATCH 12/14] refactor(frontend): remove notification bell from user bar for now --- src/components/Layout/index.tsx | 2 -- 1 file changed, 2 deletions(-) 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 }) => {
-
From 6beac736efcf7b9102e02e43b75d91a9a158cd22 Mon Sep 17 00:00:00 2001 From: sct Date: Fri, 18 Dec 2020 19:40:21 +0900 Subject: [PATCH 13/14] feat: api key regeneration --- overseerr-api.yml | 13 +++++++ server/lib/settings.ts | 2 +- server/routes/settings.ts | 39 +++++++++++++++----- src/components/Settings/CopyButton.tsx | 5 ++- src/components/Settings/SettingsMain.tsx | 46 ++++++++++++++++++++++-- src/i18n/locale/en.json | 4 +++ 6 files changed, 96 insertions(+), 13 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index 752a4fe7..e6faa7f2 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1028,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 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/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/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 (