diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 0e6282c9a..e35250a87 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -5,17 +5,21 @@ import { Column, CreateDateColumn, UpdateDateColumn, - TableInheritance, AfterUpdate, AfterInsert, getRepository, + OneToMany, } from 'typeorm'; 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 RadarrAPI from '../api/radarr'; +import logger from '../logger'; +import SeasonRequest from './SeasonRequest'; @Entity() -@TableInheritance({ column: { type: 'varchar', name: 'type' } }) export class MediaRequest { @PrimaryGeneratedColumn() public id: number; @@ -38,9 +42,15 @@ export class MediaRequest { @UpdateDateColumn() public updatedAt: Date; - @Column() + @Column({ type: 'varchar' }) public type: MediaType; + @OneToMany(() => SeasonRequest, (season) => season.request, { + eager: true, + cascade: true, + }) + public seasons: SeasonRequest[]; + constructor(init?: Partial) { Object.assign(this, init); } @@ -54,4 +64,50 @@ export class MediaRequest { mediaRepository.save(this.media); } } + + @AfterUpdate() + @AfterInsert() + private async sendToRadarr() { + if ( + this.status === MediaRequestStatus.APPROVED && + this.type === MediaType.MOVIE + ) { + try { + const settings = getSettings(); + if (settings.radarr.length === 0 && !settings.radarr[0]) { + logger.info( + 'Skipped radarr request as there is no radarr configured', + { label: 'Media Request' } + ); + return; + } + + const tmdb = new TheMovieDb(); + const radarrSettings = settings.radarr[0]; + const radarr = new RadarrAPI({ + apiKey: radarrSettings.apiKey, + url: `${radarrSettings.useSsl ? 'https' : 'http'}://${ + radarrSettings.hostname + }:${radarrSettings.port}/api`, + }); + const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); + + await radarr.addMovie({ + profileId: radarrSettings.activeProfileId, + qualityProfileId: radarrSettings.activeProfileId, + rootFolderPath: radarrSettings.activeDirectory, + title: movie.title, + tmdbId: movie.id, + year: Number(movie.release_date.slice(0, 4)), + monitored: true, + searchNow: true, + }); + logger.info('Sent request to Radarr', { label: 'Media Request' }); + } catch (e) { + throw new Error( + `[MediaRequest] Request failed to send to radarr: ${e.message}` + ); + } + } + } } diff --git a/server/entity/MovieRequest.ts b/server/entity/MovieRequest.ts deleted file mode 100644 index d34c752c0..000000000 --- a/server/entity/MovieRequest.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { MediaRequest } from './MediaRequest'; -import { ChildEntity, AfterUpdate, AfterInsert } from 'typeorm'; -import TheMovieDb from '../api/themoviedb'; -import RadarrAPI from '../api/radarr'; -import { getSettings } from '../lib/settings'; -import { MediaType, MediaRequestStatus } from '../constants/media'; -import logger from '../logger'; - -@ChildEntity(MediaType.MOVIE) -class MovieRequest extends MediaRequest { - constructor(init?: Partial) { - super(init); - } - - @AfterUpdate() - @AfterInsert() - private async sendToRadarr() { - if (this.status === MediaRequestStatus.APPROVED) { - try { - const settings = getSettings(); - if (settings.radarr.length === 0 && !settings.radarr[0]) { - logger.info( - 'Skipped radarr request as there is no radarr configured', - { label: 'Media Request' } - ); - return; - } - - const tmdb = new TheMovieDb(); - const radarrSettings = settings.radarr[0]; - const radarr = new RadarrAPI({ - apiKey: radarrSettings.apiKey, - url: `${radarrSettings.useSsl ? 'https' : 'http'}://${ - radarrSettings.hostname - }:${radarrSettings.port}/api`, - }); - const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); - - await radarr.addMovie({ - profileId: radarrSettings.activeProfileId, - qualityProfileId: radarrSettings.activeProfileId, - rootFolderPath: radarrSettings.activeDirectory, - title: movie.title, - tmdbId: movie.id, - year: Number(movie.release_date.slice(0, 4)), - monitored: true, - searchNow: true, - }); - logger.info('Sent request to Radarr', { label: 'Media Request' }); - } catch (e) { - throw new Error( - `[MediaRequest] Request failed to send to radarr: ${e.message}` - ); - } - } - } -} - -export default MovieRequest; diff --git a/server/entity/SeasonRequest.ts b/server/entity/SeasonRequest.ts index 2f841863d..846bbe164 100644 --- a/server/entity/SeasonRequest.ts +++ b/server/entity/SeasonRequest.ts @@ -6,8 +6,8 @@ import { UpdateDateColumn, ManyToOne, } from 'typeorm'; -import TvRequest from './TvRequest'; import { MediaRequestStatus } from '../constants/media'; +import { MediaRequest } from './MediaRequest'; @Entity() class SeasonRequest { @@ -20,8 +20,8 @@ class SeasonRequest { @Column({ type: 'int', default: MediaRequestStatus.PENDING }) public status: MediaRequestStatus; - @ManyToOne(() => TvRequest, (request) => request.seasons) - public request: TvRequest; + @ManyToOne(() => MediaRequest, (request) => request.seasons) + public request: MediaRequest; @CreateDateColumn() public createdAt: Date; diff --git a/server/entity/TvRequest.ts b/server/entity/TvRequest.ts deleted file mode 100644 index d6faa6b0f..000000000 --- a/server/entity/TvRequest.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { MediaRequest } from './MediaRequest'; -import { ChildEntity, OneToMany } from 'typeorm'; -import SeasonRequest from './SeasonRequest'; -import { MediaType } from '../constants/media'; - -@ChildEntity(MediaType.TV) -class TvRequest extends MediaRequest { - @OneToMany(() => SeasonRequest, (season) => season.request) - public seasons: SeasonRequest[]; - - constructor(init?: Partial) { - super(init); - } -} - -export default TvRequest; diff --git a/server/models/Tv.ts b/server/models/Tv.ts index 9103b99e9..f303c95c2 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -40,7 +40,7 @@ interface Season { seasonNumber: number; } -interface SeasonWithEpisodes extends Season { +export interface SeasonWithEpisodes extends Season { episodes: Episode[]; externalIds: ExternalIds; } diff --git a/server/routes/request.ts b/server/routes/request.ts index 29ef44ea7..94f3b6251 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -5,9 +5,8 @@ import { getRepository } from 'typeorm'; import { MediaRequest } from '../entity/MediaRequest'; import TheMovieDb from '../api/themoviedb'; import Media from '../entity/Media'; -import MovieRequest from '../entity/MovieRequest'; import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media'; -import TvRequest from '../entity/TvRequest'; +import SeasonRequest from '../entity/SeasonRequest'; const requestRoutes = Router(); @@ -43,6 +42,7 @@ requestRoutes.post( async (req, res, next) => { const tmdb = new TheMovieDb(); const mediaRepository = getRepository(Media); + const requestRepository = getRepository(MediaRequest); try { const tmdbMedia = @@ -52,6 +52,7 @@ requestRoutes.post( let media = await mediaRepository.findOne({ where: { tmdbId: req.body.mediaId }, + relations: ['requests'], }); if (!media) { @@ -65,9 +66,8 @@ requestRoutes.post( } if (req.body.mediaType === 'movie') { - const requestRepository = getRepository(MovieRequest); - - const request = new MovieRequest({ + const request = new MediaRequest({ + type: MediaType.MOVIE, media, requestedBy: req.user, // If the user is an admin or has the "auto approve" permission, automatically approve the request @@ -79,15 +79,50 @@ requestRoutes.post( await requestRepository.save(request); return res.status(201).json(request); } else if (req.body.mediaType === 'tv') { - const requestRepository = getRepository(TvRequest); - - const request = new TvRequest({ + const requestedSeasons = req.body.seasons as number[]; + let existingSeasons: number[] = []; + + // We need to check existing requests on this title to make sure we don't double up on seasons that were + // already requested. In the case they were, we just throw out any duplicates but still approve the request. + // (Unless there are no seasons, in which case we abort) + if (media.requests) { + existingSeasons = media.requests.reduce((seasons, request) => { + const combinedSeasons = request.seasons.map( + (season) => season.seasonNumber + ); + + return [...seasons, ...combinedSeasons]; + }, [] as number[]); + } + + const finalSeasons = requestedSeasons.filter( + (rs) => !existingSeasons.includes(rs) + ); + + if (finalSeasons.length === 0) { + return next({ + status: 500, + message: 'No seasons available to request', + }); + } + + const request = new MediaRequest({ + type: MediaType.TV, media, requestedBy: req.user, // If the user is an admin or has the "auto approve" permission, automatically approve the request status: req.user?.hasPermission(Permission.AUTO_APPROVE) ? MediaRequestStatus.APPROVED : MediaRequestStatus.PENDING, + seasons: finalSeasons.map( + (sn) => + new SeasonRequest({ + seasonNumber: sn, + status: req.user?.hasPermission(Permission.AUTO_APPROVE) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + }) + ), }); await requestRepository.save(request); diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx index 597ca089e..ac39b1adb 100644 --- a/src/components/Common/Button/index.tsx +++ b/src/components/Common/Button/index.tsx @@ -25,7 +25,7 @@ const Button: React.FC = ({ switch (buttonType) { case 'primary': buttonStyle.push( - 'text-white bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700 focus:shadow-outline-indigo active:bg-indigo-700' + 'text-white bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700 focus:shadow-outline-indigo active:bg-indigo-700 disabled:opacity-50' ); break; case 'danger': diff --git a/src/components/Common/Modal/index.tsx b/src/components/Common/Modal/index.tsx index 5d7e4b4df..f1740c666 100644 --- a/src/components/Common/Modal/index.tsx +++ b/src/components/Common/Modal/index.tsx @@ -1,16 +1,18 @@ -import React, { MouseEvent, ReactNode } from 'react'; +import React, { MouseEvent, ReactNode, useRef } from 'react'; 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'; +import useClickOutside from '../../../hooks/useClickOutside'; interface ModalProps { title?: string; - onCancel?: (e: MouseEvent) => void; + onCancel?: (e?: MouseEvent) => void; onOk?: (e: MouseEvent) => void; cancelText?: string; okText?: string; + okDisabled?: boolean; cancelButtonType?: ButtonType; okButtonType?: ButtonType; visible?: boolean; @@ -26,6 +28,7 @@ const Modal: React.FC = ({ onOk, cancelText, okText, + okDisabled = false, cancelButtonType, okButtonType, children, @@ -35,11 +38,17 @@ const Modal: React.FC = ({ iconSvg, loading = false, }) => { + const modalRef = useRef(null); + useClickOutside(modalRef, () => { + typeof onCancel === 'function' && backgroundClickable + ? onCancel() + : undefined; + }); useLockBodyScroll(!!visible, disableScrollLock); const transitions = useTransition(visible, null, { - from: { opacity: 0 }, - enter: { opacity: 1 }, - leave: { opacity: 0 }, + from: { opacity: 0, backdropFilter: 'blur(0px)' }, + enter: { opacity: 1, backdropFilter: 'blur(3px)' }, + leave: { opacity: 0, backdropFilter: 'blur(0px)' }, config: { tension: 500, velocity: 40, friction: 60 }, }); const containerTransitions = useTransition(visible && !loading, null, { @@ -68,15 +77,10 @@ const Modal: React.FC = ({ className="fixed top-0 left-0 right-0 bottom-0 bg-cool-gray-800 bg-opacity-50 w-full h-full z-50 flex justify-center items-center" style={props} key={key} - onClick={ - typeof onCancel === 'function' && backgroundClickable - ? onCancel - : undefined - } onKeyDown={(e) => { if (e.key === 'Escape') { typeof onCancel === 'function' && backgroundClickable - ? onCancel + ? onCancel() : undefined; } }} @@ -84,7 +88,10 @@ const Modal: React.FC = ({ {loadingTransitions.map( ({ props, item, key }) => item && ( - + ) @@ -94,13 +101,14 @@ const Modal: React.FC = ({ item && ( -
+
{iconSvg && (
{iconSvg} @@ -115,22 +123,23 @@ const Modal: React.FC = ({ {title} )} - {children && ( -
-

- {children} -

-
- )}
+ {children && ( +
+

+ {children} +

+
+ )} {(onCancel || onOk) && ( -
+
{typeof onOk === 'function' && ( @@ -139,7 +148,7 @@ const Modal: React.FC = ({ diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx new file mode 100644 index 000000000..ace40931c --- /dev/null +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -0,0 +1,258 @@ +import React, { useState } from 'react'; +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 { useToasts } from 'react-toast-notifications'; +import axios from 'axios'; +import type { MediaStatus } from '../../../server/constants/media'; +import { TvDetails, SeasonWithEpisodes } from '../../../server/models/Tv'; + +const messages = defineMessages({ + requestadmin: 'Your request will be immediately approved.', + cancelrequest: + 'This will remove your request. Are you sure you want to continue?', +}); + +interface RequestModalProps { + request?: MediaRequest; + tmdbId: number; + visible?: boolean; + onCancel?: () => void; + onComplete?: (newStatus: MediaStatus) => void; + onUpdating?: (isUpdating: boolean) => void; +} + +const TvRequestModal: React.FC = ({ + visible, + onCancel, + onComplete, + request, + tmdbId, + onUpdating, +}) => { + const { addToast } = useToasts(); + const { data, error } = useSWR( + visible ? `/api/v1/tv/${tmdbId}` : null + ); + const [selectedSeasons, setSelectedSeasons] = useState([]); + const intl = useIntl(); + const { hasPermission } = useUser(); + + const sendRequest = async () => { + if (selectedSeasons.length === 0) { + return; + } + if (onUpdating) { + onUpdating(true); + } + const response = await axios.post('/api/v1/request', { + mediaId: data?.id, + tvdbId: data?.externalIds.tvdbId, + mediaType: 'tv', + seasons: selectedSeasons, + }); + + if (response.data) { + if (onComplete) { + onComplete(response.data.media.status); + } + addToast( + + {data?.name} succesfully requested! + , + { appearance: 'success', autoDismiss: true } + ); + if (onUpdating) { + onUpdating(false); + } + } + }; + + const isSelectedSeason = (seasonNumber: number): boolean => { + return selectedSeasons.includes(seasonNumber); + }; + + const toggleSeason = (seasonNumber: number): void => { + if (selectedSeasons.includes(seasonNumber)) { + setSelectedSeasons((seasons) => + seasons.filter((sn) => sn !== seasonNumber) + ); + } else { + setSelectedSeasons((seasons) => [...seasons, seasonNumber]); + } + }; + + const toggleAllSeasons = (): void => { + if ( + data && + selectedSeasons.length >= 0 && + selectedSeasons.length < + data?.seasons.filter((season) => season.seasonNumber !== 0).length + ) { + setSelectedSeasons( + data.seasons + .filter((season) => season.seasonNumber !== 0) + .map((season) => season.seasonNumber) + ); + } else { + setSelectedSeasons([]); + } + }; + + const isAllSeasons = (): boolean => { + if (!data) { + return false; + } + return ( + selectedSeasons.length === + data.seasons.filter((season) => season.seasonNumber !== 0).length + ); + }; + + const text = hasPermission(Permission.MANAGE_REQUESTS) + ? intl.formatMessage(messages.requestadmin) + : undefined; + + return ( + sendRequest()} + title={`Request ${data?.name}`} + okText={ + selectedSeasons.length === 0 + ? 'Select a season' + : `Request ${selectedSeasons.length} seasons` + } + okDisabled={selectedSeasons.length === 0} + okButtonType="primary" + iconSvg={ + + + + } + > +
+
+
+
+ + + + + + + + + + + {data?.seasons + .filter((season) => season.seasonNumber !== 0) + .map((season) => ( + + + + + + + ))} + +
+ toggleAllSeasons()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Space') { + toggleAllSeasons(); + } + }} + className="group relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none" + > + + + + + Season + + # Of Episodes + + Status +
+ toggleSeason(season.seasonNumber)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Space') { + toggleSeason(season.seasonNumber); + } + }} + className="group relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none" + > + + + + + {season.seasonNumber === 0 + ? 'Extras' + : `Season ${season.seasonNumber}`} + + {season.episodeCount} + + + Available + +
+
+
+
+
+

{text}

+
+ ); +}; + +export default TvRequestModal; diff --git a/src/components/RequestModal/index.tsx b/src/components/RequestModal/index.tsx index bfd57ee58..3a118bd7e 100644 --- a/src/components/RequestModal/index.tsx +++ b/src/components/RequestModal/index.tsx @@ -3,6 +3,7 @@ import useSWR from 'swr'; import MovieRequestModal from './MovieRequestModal'; import type { MediaRequest } from '../../../server/entity/MediaRequest'; import type { MediaStatus } from '../../../server/constants/media'; +import TvRequestModal from './TvRequestModal'; interface RequestModalProps { requestId?: number; @@ -29,7 +30,16 @@ const RequestModal: React.FC = ({ requestId ? `/api/v1/request/${requestId}` : null ); if (type === 'tv') { - return null; + return ( + + ); } return ( diff --git a/tailwind.config.js b/tailwind.config.js index 30c9df659..ddc0c6ba9 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -13,6 +13,8 @@ module.exports = { variants: { padding: ['first', 'last'], borderWidth: ['first', 'last'], + boxShadow: ['group-focus'], + opacity: ['disabled'], }, plugins: [ require('@tailwindcss/ui')({