diff --git a/overseerr-api.yml b/overseerr-api.yml index 5647bb5ed..2b6843117 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -736,6 +736,21 @@ components: name: type: string example: 720p/1080p + PageInfo: + type: object + properties: + page: + type: number + example: 1 + pages: + type: number + example: 10 + pageSize: + type: number + example: 10 + results: + type: number + example: 100 securitySchemes: cookieAuth: @@ -1847,6 +1862,45 @@ paths: type: array items: $ref: '#/components/schemas/TvResult' + /media: + get: + summary: Return all media + description: Returns all media (can be filtered and limited) in JSON format + tags: + - media + parameters: + - in: query + name: take + schema: + type: number + nullable: true + example: 20 + - in: query + name: skip + schema: + type: number + nullable: true + example: 0 + - in: query + name: filter + schema: + type: string + nullable: true + enum: [all, available, partial, processing, pending] + responses: + '200': + description: Returned media + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/MediaInfo' security: - cookieAuth: [] diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 036bd0eb9..0ba09fb7b 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -151,7 +151,8 @@ export class MediaRequest { }); const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); - await radarr.addMovie({ + // Run this asynchronously so we don't wait for it on the UI side + radarr.addMovie({ profileId: radarrSettings.activeProfileId, qualityProfileId: radarrSettings.activeProfileId, rootFolderPath: radarrSettings.activeDirectory, @@ -212,7 +213,8 @@ export class MediaRequest { throw new Error('Series was missing tvdb id'); } - await sonarr.addSeries({ + // Run this asynchronously so we don't wait for it on the UI side + sonarr.addSeries({ profileId: sonarrSettings.activeProfileId, rootFolderPath: sonarrSettings.activeDirectory, title: series.name, diff --git a/server/routes/index.ts b/server/routes/index.ts index 88e800917..8c04bf9b7 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -10,6 +10,7 @@ import discoverRoutes from './discover'; import requestRoutes from './request'; import movieRoutes from './movie'; import tvRoutes from './tv'; +import mediaRoutes from './media'; const router = Router(); @@ -25,6 +26,7 @@ router.use('/discover', isAuthenticated(), discoverRoutes); router.use('/request', isAuthenticated(), requestRoutes); router.use('/movie', isAuthenticated(), movieRoutes); router.use('/tv', isAuthenticated(), tvRoutes); +router.use('/media', isAuthenticated(), mediaRoutes); router.use('/auth', authRoutes); router.get('/settings/public', (_req, res) => { diff --git a/server/routes/media.ts b/server/routes/media.ts new file mode 100644 index 000000000..733030366 --- /dev/null +++ b/server/routes/media.ts @@ -0,0 +1,71 @@ +import { Router } from 'express'; +import { getRepository, FindOperator } from 'typeorm'; +import Media from '../entity/Media'; +import { MediaStatus } from '../constants/media'; + +export interface MediaResultsResponse { + pageInfo: { + pages: number; + page: number; + results: number; + pageSize: number; + }; + results: Media[]; +} + +const mediaRoutes = Router(); + +mediaRoutes.get('/', async (req, res, next) => { + const mediaRepository = getRepository(Media); + + const pageSize = Number(req.query.take) ?? 20; + const skip = Number(req.query.skip) ?? 0; + + let statusFilter: + | MediaStatus + | FindOperator + | undefined = undefined; + + switch (req.query.filter) { + case 'available': + statusFilter = MediaStatus.AVAILABLE; + break; + case 'partial': + statusFilter = MediaStatus.PARTIALLY_AVAILABLE; + break; + case 'processing': + statusFilter = MediaStatus.PROCESSING; + break; + case 'pending': + statusFilter = MediaStatus.PENDING; + break; + default: + statusFilter = undefined; + } + + try { + const [media, mediaCount] = await mediaRepository.findAndCount({ + order: { + id: 'DESC', + }, + where: { + status: statusFilter, + }, + take: pageSize, + skip, + }); + return res.status(200).json({ + pageInfo: { + pages: Math.ceil(mediaCount / pageSize), + pageSize, + results: mediaCount, + page: Math.ceil(skip / pageSize) + 1, + }, + results: media, + } as MediaResultsResponse); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + +export default mediaRoutes; diff --git a/server/routes/request.ts b/server/routes/request.ts index fbea4bff2..2b4a4023e 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -20,7 +20,8 @@ requestRoutes.get('/', async (req, res, next) => { id: 'DESC', }, relations: ['media'], - take: 20, + take: Number(req.query.take) ?? 20, + skip: Number(req.query.skip) ?? 0, }) : await requestRepository.find({ where: { requestedBy: { id: req.user?.id } }, @@ -28,7 +29,8 @@ requestRoutes.get('/', async (req, res, next) => { order: { id: 'DESC', }, - take: 20, + take: Number(req.query.limit) ?? 20, + skip: Number(req.query.skip) ?? 0, }); return res.status(200).json(requests); diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 07cb5443e..7434aff70 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -6,13 +6,17 @@ import { MediaRequest } from '../../../server/entity/MediaRequest'; import RequestCard from '../TitleCard/RequestCard'; import Slider from '../Slider'; import Link from 'next/link'; -import { defineMessages, FormattedMessage } from 'react-intl'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { LanguageContext } from '../../context/LanguageContext'; +import type Media from '../../../server/entity/Media'; +import type { MediaResultsResponse } from '../../../server/routes/media'; const messages = defineMessages({ recentrequests: 'Recent Requests', popularmovies: 'Popular Movies', populartv: 'Popular Series', + recentlyAdded: 'Recently Added', + nopending: 'No Pending Requests', }); interface MovieDiscoverResult { @@ -30,6 +34,7 @@ interface TvDiscoverResult { } const Discover: React.FC = () => { + const intl = useIntl(); const { locale } = useContext(LanguageContext); const { data: movieData, error: movieError } = useSWR( `/api/v1/discover/movies?language=${locale}` @@ -38,12 +43,53 @@ const Discover: React.FC = () => { `/api/v1/discover/tv?language=${locale}` ); + const { data: media, error: mediaError } = useSWR( + '/api/v1/media?filter=available&take=20' + ); + const { data: requests, error: requestError } = useSWR( '/api/v1/request' ); return ( <> +
+ +
+ ( + + ))} + />
@@ -80,6 +126,7 @@ const Discover: React.FC = () => { type={request.media.mediaType} /> ))} + emptyMessage={intl.formatMessage(messages.nopending)} />
diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index 4def4d241..00b3131f3 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState, useEffect } from 'react'; import Modal from '../Common/Modal'; import { useUser } from '../../hooks/useUser'; import { Permission } from '../../../server/lib/permissions'; @@ -35,6 +35,7 @@ const MovieRequestModal: React.FC = ({ onUpdating, ...props }) => { + const [isUpdating, setIsUpdating] = useState(false); const { addToast } = useToasts(); const { data, error } = useSWR(`/api/v1/movie/${tmdbId}`, { revalidateOnMount: true, @@ -42,10 +43,14 @@ const MovieRequestModal: React.FC = ({ const intl = useIntl(); const { user, hasPermission } = useUser(); - const sendRequest = useCallback(async () => { + useEffect(() => { if (onUpdating) { - onUpdating(true); + onUpdating(isUpdating); } + }, [isUpdating, onUpdating]); + + const sendRequest = useCallback(async () => { + setIsUpdating(true); const response = await axios.post('/api/v1/request', { mediaId: data?.id, mediaType: 'movie', @@ -61,18 +66,14 @@ const MovieRequestModal: React.FC = ({ , { appearance: 'success', autoDismiss: true } ); - if (onUpdating) { - onUpdating(false); - } + setIsUpdating(false); } - }, [data, onComplete, onUpdating, addToast]); + }, [data, onComplete, addToast]); const activeRequest = data?.mediaInfo?.requests?.[0]; const cancelRequest = async () => { - if (onUpdating) { - onUpdating(true); - } + setIsUpdating(true); const response = await axios.delete( `/api/v1/request/${activeRequest?.id}` ); @@ -87,9 +88,7 @@ const MovieRequestModal: React.FC = ({ , { appearance: 'success', autoDismiss: true } ); - if (onUpdating) { - onUpdating(false); - } + setIsUpdating(false); } }; @@ -109,8 +108,9 @@ const MovieRequestModal: React.FC = ({ backgroundClickable onCancel={onCancel} onOk={isOwner ? () => cancelRequest() : undefined} + okDisabled={isUpdating} title={`Pending request for ${data?.title}`} - okText={'Cancel Request'} + okText={isUpdating ? 'Cancelling...' : 'Cancel Request'} okButtonType={'danger'} cancelText="Close" iconSvg={} @@ -128,8 +128,9 @@ const MovieRequestModal: React.FC = ({ backgroundClickable onCancel={onCancel} onOk={sendRequest} + okDisabled={isUpdating} title={`Request ${data?.title}`} - okText={'Request'} + okText={isUpdating ? 'Requesting...' : 'Request'} okButtonType={'primary'} iconSvg={} {...props} diff --git a/src/components/Slider/index.tsx b/src/components/Slider/index.tsx index 97c231915..d97629b1e 100644 --- a/src/components/Slider/index.tsx +++ b/src/components/Slider/index.tsx @@ -2,12 +2,18 @@ import { debounce } from 'lodash'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useSpring } from 'react-spring'; import TitleCard from '../TitleCard'; +import { defineMessages, FormattedMessage } from 'react-intl'; + +const messages = defineMessages({ + noresults: 'No Results', +}); interface SliderProps { sliderKey: string; items?: JSX.Element[]; isLoading: boolean; isEmpty: boolean; + emptyMessage?: string; } enum Direction { @@ -20,6 +26,7 @@ const Slider: React.FC = ({ items, isLoading, isEmpty, + emptyMessage, }) => { const containerRef = useRef(null); const [scrollPos, setScrollPos] = useState({ isStart: true, isEnd: false }); @@ -208,7 +215,13 @@ const Slider: React.FC = ({
))} {isEmpty && ( -
No Results
+
+ {emptyMessage ? ( + emptyMessage + ) : ( + + )} +
)}