From 2bf7e10e32718b36799be2feb0a7f9ff54d85744 Mon Sep 17 00:00:00 2001 From: sct Date: Wed, 7 Oct 2020 10:01:15 +0000 Subject: [PATCH] feat(frontend/api): beginning of new request modal also includes new api endpoints for seasons --- server/api/themoviedb.ts | 43 +++++++++++-- server/entity/Media.ts | 6 +- server/job/plexsync.ts | 1 - server/models/Tv.ts | 34 +++++++--- server/overseerr-api.yml | 35 +++++++++++ server/routes/tv.ts | 14 ++++- src/components/Common/Modal/index.tsx | 19 +++++- src/components/MovieDetails/index.tsx | 49 +++------------ .../RequestModal/MovieRequestModal.tsx | 62 +++++++++++++++---- src/components/RequestModal/index.tsx | 47 ++++++++++++++ src/components/TitleCard/index.tsx | 50 +++------------ 11 files changed, 245 insertions(+), 115 deletions(-) create mode 100644 src/components/RequestModal/index.tsx diff --git a/server/api/themoviedb.ts b/server/api/themoviedb.ts index 170b77e12..337db3714 100644 --- a/server/api/themoviedb.ts +++ b/server/api/themoviedb.ts @@ -183,7 +183,7 @@ export interface TmdbMovieDetails { external_ids: TmdbExternalIds; } -export interface TmdbTvEpisodeDetails { +export interface TmdbTvEpisodeResult { id: number; air_date: string; episode_number: number; @@ -197,13 +197,13 @@ export interface TmdbTvEpisodeDetails { vote_cuont: number; } -export interface TmdbTvSeasonDetails { +export interface TmdbTvSeasonResult { id: number; air_date: string; episode_count: number; name: string; overview: string; - poster_path: string; + poster_path?: string; season_number: number; } @@ -227,9 +227,9 @@ export interface TmdbTvDetails { in_production: boolean; languages: string[]; last_air_date: string; - last_episode_to_air?: TmdbTvEpisodeDetails; + last_episode_to_air?: TmdbTvEpisodeResult; name: string; - next_episode_to_air?: TmdbTvEpisodeDetails; + next_episode_to_air?: TmdbTvEpisodeResult; networks: { id: number; name: string; @@ -250,7 +250,7 @@ export interface TmdbTvDetails { name: string; origin_country: string; }[]; - seasons: TmdbTvSeasonDetails[]; + seasons: TmdbTvSeasonResult[]; status: string; type: string; vote_average: number; @@ -262,6 +262,11 @@ export interface TmdbTvDetails { external_ids: TmdbExternalIds; } +export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult { + episodes: TmdbTvEpisodeResult[]; + external_ids: TmdbExternalIds; +} + class TheMovieDb { private apiKey = 'db55323b8d3e4154498498a75642b381'; private axios: AxiosInstance; @@ -335,6 +340,32 @@ class TheMovieDb { } }; + public getTvSeason = async ({ + tvId, + seasonNumber, + language, + }: { + tvId: number; + seasonNumber: number; + language?: string; + }): Promise => { + try { + const response = await this.axios.get( + `/tv/${tvId}/season/${seasonNumber}`, + { + params: { + language, + append_to_response: 'external_ids', + }, + } + ); + + return response.data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`); + } + }; + public async getMovieRecommendations({ movieId, page = 1, diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 88ad6c9d1..002eca0ef 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -43,13 +43,13 @@ class Media { const mediaRepository = getRepository(Media); try { - const media = await mediaRepository.findOneOrFail({ + const media = await mediaRepository.findOne({ where: { tmdbId: id }, }); return media; } catch (e) { - logger.error(e.messaage); + logger.error(e.message); return undefined; } } @@ -76,7 +76,7 @@ class Media { public status: MediaStatus; @OneToMany(() => MediaRequest, (request) => request.media) - public requests: MediaRequest; + public requests: MediaRequest[]; @CreateDateColumn() public createdAt: Date; diff --git a/server/job/plexsync.ts b/server/job/plexsync.ts index 57085d7ee..bb1c058fe 100644 --- a/server/job/plexsync.ts +++ b/server/job/plexsync.ts @@ -6,7 +6,6 @@ import Media from '../entity/Media'; import { MediaStatus, MediaType } from '../constants/media'; import logger from '../logger'; import { getSettings, Library } from '../lib/settings'; -import { resolve } from 'dns'; const BUNDLE_SIZE = 10; diff --git a/server/models/Tv.ts b/server/models/Tv.ts index 7edd8ba20..9103b99e9 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -9,9 +9,10 @@ import { mapExternalIds, } from './common'; import { - TmdbTvEpisodeDetails, - TmdbTvSeasonDetails, + TmdbTvEpisodeResult, + TmdbTvSeasonResult, TmdbTvDetails, + TmdbSeasonWithEpisodes, } from '../api/themoviedb'; import type Media from '../entity/Media'; @@ -39,6 +40,11 @@ interface Season { seasonNumber: number; } +interface SeasonWithEpisodes extends Season { + episodes: Episode[]; + externalIds: ExternalIds; +} + export interface TvDetails { id: number; backdropPath?: string; @@ -81,7 +87,7 @@ export interface TvDetails { mediaInfo?: Media; } -const mapEpisodeDetails = (episode: TmdbTvEpisodeDetails): Episode => ({ +const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({ id: episode.id, airDate: episode.air_date, episodeNumber: episode.episode_number, @@ -95,7 +101,7 @@ const mapEpisodeDetails = (episode: TmdbTvEpisodeDetails): Episode => ({ stillPath: episode.still_path, }); -const mapSeasonDetails = (season: TmdbTvSeasonDetails): Season => ({ +const mapSeasonResult = (season: TmdbTvSeasonResult): Season => ({ airDate: season.air_date, episodeCount: season.episode_count, id: season.id, @@ -105,6 +111,20 @@ const mapSeasonDetails = (season: TmdbTvSeasonDetails): Season => ({ posterPath: season.poster_path, }); +export const mapSeasonWithEpisodes = ( + season: TmdbSeasonWithEpisodes +): SeasonWithEpisodes => ({ + airDate: season.air_date, + episodeCount: season.episode_count, + episodes: season.episodes.map(mapEpisodeResult), + externalIds: mapExternalIds(season.external_ids), + id: season.id, + name: season.name, + overview: season.overview, + seasonNumber: season.season_number, + posterPath: season.poster_path, +}); + export const mapTvDetails = ( show: TmdbTvDetails, media?: Media @@ -141,17 +161,17 @@ export const mapTvDetails = ( originCountry: company.origin_country, logoPath: company.logo_path, })), - seasons: show.seasons.map(mapSeasonDetails), + seasons: show.seasons.map(mapSeasonResult), status: show.status, type: show.type, voteAverage: show.vote_average, voteCount: show.vote_count, backdropPath: show.backdrop_path, lastEpisodeToAir: show.last_episode_to_air - ? mapEpisodeDetails(show.last_episode_to_air) + ? mapEpisodeResult(show.last_episode_to_air) : undefined, nextEpisodeToAir: show.next_episode_to_air - ? mapEpisodeDetails(show.next_episode_to_air) + ? mapEpisodeResult(show.next_episode_to_air) : undefined, posterPath: show.poster_path, credits: { diff --git a/server/overseerr-api.yml b/server/overseerr-api.yml index adc156796..f574aaa66 100644 --- a/server/overseerr-api.yml +++ b/server/overseerr-api.yml @@ -479,6 +479,10 @@ components: type: string seasonNumber: type: number + episodes: + type: array + items: + $ref: '#/components/schemas/Episode' TvDetails: type: object properties: @@ -1554,6 +1558,37 @@ paths: application/json: schema: $ref: '#/components/schemas/TvDetails' + /tv/{tvId}/season/{seasonId}: + get: + summary: Return season details with episode list + description: Returns back season details with a list of episodes + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: number + example: 76479 + - in: path + name: seasonId + required: true + schema: + type: number + example: 1 + - in: query + name: language + schema: + type: string + example: en-US + responses: + '200': + description: TV details + content: + application/json: + schema: + $ref: '#/components/schemas/Season' /tv/{tvId}/recommendations: get: summary: Request recommended tv series diff --git a/server/routes/tv.ts b/server/routes/tv.ts index a814fe2d7..43034a5e6 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import TheMovieDb from '../api/themoviedb'; import { MediaRequest } from '../entity/MediaRequest'; -import { mapTvDetails } from '../models/Tv'; +import { mapTvDetails, mapSeasonWithEpisodes } from '../models/Tv'; import { mapTvResult } from '../models/Search'; import Media from '../entity/Media'; @@ -20,6 +20,18 @@ tvRoutes.get('/:id', async (req, res) => { return res.status(200).json(mapTvDetails(tv, media)); }); +tvRoutes.get('/:id/season/:seasonNumber', async (req, res) => { + const tmdb = new TheMovieDb(); + + const season = await tmdb.getTvSeason({ + tvId: Number(req.params.id), + seasonNumber: Number(req.params.seasonNumber), + language: req.query.language as string, + }); + + return res.status(200).json(mapSeasonWithEpisodes(season)); +}); + tvRoutes.get('/:id/recommendations', async (req, res) => { const tmdb = new TheMovieDb(); diff --git a/src/components/Common/Modal/index.tsx b/src/components/Common/Modal/index.tsx index 4901800a9..5d7e4b4df 100644 --- a/src/components/Common/Modal/index.tsx +++ b/src/components/Common/Modal/index.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom'; import Button, { ButtonType } from '../Button'; import { useTransition, animated } from 'react-spring'; import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll'; +import LoadingSpinner from '../LoadingSpinner'; interface ModalProps { title?: string; @@ -16,6 +17,7 @@ interface ModalProps { disableScrollLock?: boolean; backgroundClickable?: boolean; iconSvg?: ReactNode; + loading?: boolean; } const Modal: React.FC = ({ @@ -31,6 +33,7 @@ const Modal: React.FC = ({ disableScrollLock, backgroundClickable = true, iconSvg, + loading = false, }) => { useLockBodyScroll(!!visible, disableScrollLock); const transitions = useTransition(visible, null, { @@ -39,7 +42,13 @@ const Modal: React.FC = ({ leave: { opacity: 0 }, config: { tension: 500, velocity: 40, friction: 60 }, }); - const containerTransitions = useTransition(visible, null, { + const containerTransitions = useTransition(visible && !loading, null, { + from: { opacity: 0, transform: 'scale(0.5)' }, + enter: { opacity: 1, transform: 'scale(1)' }, + leave: { opacity: 0, transform: 'scale(0.5)' }, + config: { tension: 500, velocity: 40, friction: 60 }, + }); + const loadingTransitions = useTransition(visible && loading, null, { from: { opacity: 0, transform: 'scale(0.5)' }, enter: { opacity: 1, transform: 'scale(1)' }, leave: { opacity: 0, transform: 'scale(0.5)' }, @@ -72,6 +81,14 @@ const Modal: React.FC = ({ } }} > + {loadingTransitions.map( + ({ props, item, key }) => + item && ( + + + + ) + )} {containerTransitions.map( ({ props, item, key }) => item && ( diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 69d20e1cb..943e62c54 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -9,11 +9,7 @@ import { import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie'; import useSWR from 'swr'; import { useRouter } from 'next/router'; -import { useToasts } from 'react-toast-notifications'; import Button from '../Common/Button'; -import MovieRequestModal from '../RequestModal/MovieRequestModal'; -import type { MediaRequest } from '../../../server/entity/MediaRequest'; -import axios from 'axios'; import type { MovieResult } from '../../../server/models/Search'; import Link from 'next/link'; import Slider from '../Slider'; @@ -22,8 +18,8 @@ import PersonCard from '../PersonCard'; import { LanguageContext } from '../../context/LanguageContext'; import LoadingSpinner from '../Common/LoadingSpinner'; import { useUser, Permission } from '../../hooks/useUser'; -import PendingRequest from '../PendingRequest'; import { MediaStatus } from '../../../server/constants/media'; +import RequestModal from '../RequestModal'; const messages = defineMessages({ releasedate: 'Release Date', @@ -64,13 +60,11 @@ enum MediaRequestStatus { } const MovieDetails: React.FC = ({ movie }) => { - const { user, hasPermission } = useUser(); + const { hasPermission } = useUser(); const router = useRouter(); const intl = useIntl(); const { locale } = useContext(LanguageContext); - const { addToast } = useToasts(); const [showRequestModal, setShowRequestModal] = useState(false); - const [showCancelModal, setShowCancelModal] = useState(false); const { data, error, revalidate } = useSWR( `/api/v1/movie/${router.query.movieId}?language=${locale}`, { @@ -84,27 +78,6 @@ const MovieDetails: React.FC = ({ movie }) => { `/api/v1/movie/${router.query.movieId}/similar?language=${locale}` ); - const request = async () => { - const response = await axios.post('/api/v1/request', { - mediaId: data?.id, - mediaType: 'movie', - }); - - if (response.data) { - revalidate(); - addToast( - - {data?.title} succesfully requested! - , - { appearance: 'success', autoDismiss: true } - ); - } - }; - - const cancelRequest = async () => { - // fix this - }; - if (!data && !error) { return ; } @@ -121,19 +94,13 @@ const MovieDetails: React.FC = ({ movie }) => { backgroundImage: `linear-gradient(180deg, rgba(45, 55, 72, 0.47) 0%, #1A202E 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`, }} > - revalidate()} onCancel={() => setShowRequestModal(false)} - onOk={() => request()} - /> - setShowCancelModal(false)} - onOk={() => cancelRequest()} />
diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index ab2f061d3..f21a74d3c 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -3,6 +3,12 @@ import Modal from '../Common/Modal'; import { useUser } from '../../hooks/useUser'; import { Permission } from '../../../server/lib/permissions'; import { defineMessages, useIntl } from 'react-intl'; +import { MediaRequest } from '../../../server/entity/MediaRequest'; +import useSWR from 'swr'; +import { MovieDetails } from '../../../server/models/Movie'; +import { useToasts } from 'react-toast-notifications'; +import axios from 'axios'; +import type { MediaStatus } from '../../../server/constants/media'; const messages = defineMessages({ requestadmin: @@ -12,42 +18,72 @@ const messages = defineMessages({ }); interface RequestModalProps { - type: 'request' | 'cancel'; + request?: MediaRequest; + tmdbId: number; visible?: boolean; - onCancel: () => void; - onOk: () => void; - title: string; + onCancel?: () => void; + onComplete?: (newStatus: MediaStatus) => void; + onUpdating?: (isUpdating: boolean) => void; } const MovieRequestModal: React.FC = ({ - type, visible, onCancel, - onOk, - title, + onComplete, + request, + tmdbId, + onUpdating, }) => { + const { addToast } = useToasts(); + const { data, error } = useSWR(`/api/v1/movie/${tmdbId}`); const intl = useIntl(); const { hasPermission } = useUser(); + const sendRequest = async () => { + if (onUpdating) { + onUpdating(true); + } + const response = await axios.post('/api/v1/request', { + mediaId: data?.id, + mediaType: 'movie', + }); + + if (response.data) { + if (onComplete) { + onComplete(response.data.media.status); + } + addToast( + + {data?.title} succesfully requested! + , + { appearance: 'success', autoDismiss: true } + ); + if (onUpdating) { + onUpdating(false); + } + } + }; + let text = hasPermission(Permission.MANAGE_REQUESTS) ? intl.formatMessage(messages.requestadmin) : undefined; - if (type === 'cancel') { + if (request) { text = intl.formatMessage(messages.cancelrequest); } return ( sendRequest()} + title={!request ? `Request ${data?.title}` : 'Cancel Request'} + okText={!request ? 'Request' : 'Cancel Request'} + okButtonType={!!request ? 'danger' : 'primary'} iconSvg={ - type === 'request' ? ( + !request ? ( void; + onError?: (error: string) => void; + onCancel?: () => void; + onUpdating?: (isUpdating: boolean) => void; +} + +const RequestModal: React.FC = ({ + type, + requestId, + show, + tmdbId, + onComplete, + onError, + onUpdating, + onCancel, +}) => { + const { data } = useSWR( + requestId ? `/api/v1/request/${requestId}` : null + ); + if (type === 'tv') { + return null; + } + + return ( + + ); +}; + +export default RequestModal; diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index b3285e9f1..051075a92 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -12,6 +12,7 @@ import { MediaRequest } from '../../../server/entity/MediaRequest'; import MovieRequestModal from '../RequestModal/MovieRequestModal'; import Link from 'next/link'; import { MediaStatus } from '../../../server/constants/media'; +import RequestModal from '../RequestModal'; interface TitleCardProps { id: number; @@ -35,42 +36,12 @@ const TitleCard: React.FC = ({ mediaType, requestId, }) => { - const { addToast } = useToasts(); const [isUpdating, setIsUpdating] = useState(false); const [currentStatus, setCurrentStatus] = useState(status); const [showDetail, setShowDetail] = useState(false); const [showRequestModal, setShowRequestModal] = useState(false); const [showCancelModal, setShowCancelModal] = useState(false); - const request = async () => { - setIsUpdating(true); - const response = await axios.post('/api/v1/request', { - mediaId: id, - mediaType, - }); - - if (response.data) { - setCurrentStatus(response.data.media.status); - addToast( - - {title} succesfully requested! - , - { appearance: 'success', autoDismiss: true } - ); - } - setIsUpdating(false); - }; - - const cancelRequest = async () => { - const response = await axios.delete( - `/api/v1/request/${requestId}` - ); - - if (response.data.id) { - setCurrentStatus(undefined); - } - }; - // Just to get the year from the date if (year) { year = year.slice(0, 4); @@ -78,19 +49,14 @@ const TitleCard: React.FC = ({ return (
- setCurrentStatus(newStatus)} + onUpdating={(status) => setIsUpdating(status)} onCancel={() => setShowRequestModal(false)} - onOk={() => request()} - /> - setShowCancelModal(false)} - onOk={() => cancelRequest()} />