From a333a095820ce3f10857026ba4770a2fffeed7cb Mon Sep 17 00:00:00 2001 From: sct Date: Thu, 24 Dec 2020 16:31:56 +0900 Subject: [PATCH] feat: add collections (#484) closes #418 --- overseerr-api.yml | 58 ++++ server/api/themoviedb.ts | 38 +++ server/models/Collection.ts | 29 ++ server/models/Movie.ts | 14 + server/routes/collection.ts | 27 ++ server/routes/index.ts | 2 + src/components/CollectionDetails/index.tsx | 267 ++++++++++++++++++ src/components/Common/Button/index.tsx | 2 +- src/components/MovieDetails/index.tsx | 19 ++ .../RequestModal/MovieRequestModal.tsx | 6 +- src/components/TitleCard/index.tsx | 34 ++- src/i18n/locale/en.json | 9 + src/pages/collection/[collectionId]/index.tsx | 38 +++ 13 files changed, 526 insertions(+), 17 deletions(-) create mode 100644 server/models/Collection.ts create mode 100644 server/routes/collection.ts create mode 100644 src/components/CollectionDetails/index.tsx create mode 100644 src/pages/collection/[collectionId]/index.tsx diff --git a/overseerr-api.yml b/overseerr-api.yml index f6d6aab53..f1ddfc654 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -463,6 +463,19 @@ components: type: array items: $ref: '#/components/schemas/Crew' + collection: + type: object + properties: + id: + type: number + example: 1 + name: + type: string + example: A collection + posterPath: + type: string + backdropPath: + type: string externalIds: $ref: '#/components/schemas/ExternalIds' mediaInfo: @@ -991,6 +1004,26 @@ components: name: type: string example: 'English' + Collection: + type: object + properties: + id: + type: number + example: 123 + name: + type: string + example: A Movie Collection + overview: + type: string + example: Overview of collection + posterPath: + type: string + backdropPath: + type: string + parts: + type: array + items: + $ref: '#/components/schemas/MovieResult' securitySchemes: cookieAuth: type: apiKey @@ -2626,6 +2659,31 @@ paths: responses: '204': description: Succesfully removed media item + /collection/{collectionId}: + get: + summary: Request collection details + description: Returns back full collection details in JSON format + tags: + - collection + parameters: + - in: path + name: collectionId + required: true + schema: + type: number + example: 537982 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Collection details + content: + application/json: + schema: + $ref: '#/components/schemas/Collection' security: - cookieAuth: [] diff --git a/server/api/themoviedb.ts b/server/api/themoviedb.ts index 43f9f3645..4cded2639 100644 --- a/server/api/themoviedb.ts +++ b/server/api/themoviedb.ts @@ -190,6 +190,12 @@ export interface TmdbMovieDetails { cast: TmdbCreditCast[]; crew: TmdbCreditCrew[]; }; + belongs_to_collection?: { + id: number; + name: string; + poster_path?: string; + backdrop_path?: string; + }; external_ids: TmdbExternalIds; } @@ -344,6 +350,15 @@ export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult { external_ids: TmdbExternalIds; } +export interface TmdbCollection { + id: number; + name: string; + overview?: string; + poster_path?: string; + backdrop_path?: string; + parts: TmdbMovieResult[]; +} + class TheMovieDb { private apiKey = 'db55323b8d3e4154498498a75642b381'; private axios: AxiosInstance; @@ -866,6 +881,29 @@ class TheMovieDb { ); } } + + public async getCollection({ + collectionId, + language = 'en-US', + }: { + collectionId: number; + language?: string; + }): Promise { + try { + const response = await this.axios.get( + `/collection/${collectionId}`, + { + params: { + language, + }, + } + ); + + return response.data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`); + } + } } export default TheMovieDb; diff --git a/server/models/Collection.ts b/server/models/Collection.ts new file mode 100644 index 000000000..f80a1ad79 --- /dev/null +++ b/server/models/Collection.ts @@ -0,0 +1,29 @@ +import { TmdbCollection } from '../api/themoviedb'; +import Media from '../entity/Media'; +import { mapMovieResult, MovieResult } from './Search'; + +export interface Collection { + id: number; + name: string; + overview?: string; + posterPath?: string; + backdropPath?: string; + parts: MovieResult[]; +} + +export const mapCollection = ( + collection: TmdbCollection, + media: Media[] +): Collection => ({ + id: collection.id, + name: collection.name, + overview: collection.overview, + posterPath: collection.poster_path, + backdropPath: collection.backdrop_path, + parts: collection.parts.map((part) => + mapMovieResult( + part, + media?.find((req) => req.tmdbId === part.id) + ) + ), +}); diff --git a/server/models/Movie.ts b/server/models/Movie.ts index 11f357e7b..8798dd130 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -46,6 +46,12 @@ export interface MovieDetails { cast: Cast[]; crew: Crew[]; }; + collection?: { + id: number; + name: string; + posterPath?: string; + backdropPath?: string; + }; mediaInfo?: Media; externalIds: ExternalIds; } @@ -87,6 +93,14 @@ export const mapMovieDetails = ( cast: movie.credits.cast.map(mapCast), crew: movie.credits.crew.map(mapCrew), }, + collection: movie.belongs_to_collection + ? { + id: movie.belongs_to_collection.id, + name: movie.belongs_to_collection.name, + posterPath: movie.belongs_to_collection.poster_path, + backdropPath: movie.belongs_to_collection.backdrop_path, + } + : undefined, externalIds: mapExternalIds(movie.external_ids), mediaInfo: media, }); diff --git a/server/routes/collection.ts b/server/routes/collection.ts new file mode 100644 index 000000000..75f1a455f --- /dev/null +++ b/server/routes/collection.ts @@ -0,0 +1,27 @@ +import { Router } from 'express'; +import TheMovieDb from '../api/themoviedb'; +import Media from '../entity/Media'; +import { mapCollection } from '../models/Collection'; + +const collectionRoutes = Router(); + +collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => { + const tmdb = new TheMovieDb(); + + try { + const collection = await tmdb.getCollection({ + collectionId: Number(req.params.id), + language: req.query.language as string, + }); + + const media = await Media.getRelatedMedia( + collection.parts.map((part) => part.id) + ); + + return res.status(200).json(mapCollection(collection, media)); + } catch (e) { + return next({ status: 404, message: 'Collection does not exist' }); + } +}); + +export default collectionRoutes; diff --git a/server/routes/index.ts b/server/routes/index.ts index eda282daa..bf094ec08 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -12,6 +12,7 @@ import movieRoutes from './movie'; import tvRoutes from './tv'; import mediaRoutes from './media'; import personRoutes from './person'; +import collectionRoutes from './collection'; const router = Router(); @@ -34,6 +35,7 @@ router.use('/movie', isAuthenticated(), movieRoutes); router.use('/tv', isAuthenticated(), tvRoutes); router.use('/media', isAuthenticated(), mediaRoutes); router.use('/person', isAuthenticated(), personRoutes); +router.use('/collection', isAuthenticated(), collectionRoutes); router.use('/auth', authRoutes); router.get('/', (_req, res) => { diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx new file mode 100644 index 000000000..93e954ea6 --- /dev/null +++ b/src/components/CollectionDetails/index.tsx @@ -0,0 +1,267 @@ +import axios from 'axios'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import React, { useContext, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import { MediaStatus } from '../../../server/constants/media'; +import type { MediaRequest } from '../../../server/entity/MediaRequest'; +import type { Collection } from '../../../server/models/Collection'; +import { LanguageContext } from '../../context/LanguageContext'; +import globalMessages from '../../i18n/globalMessages'; +import Error from '../../pages/_error'; +import Badge from '../Common/Badge'; +import Button from '../Common/Button'; +import LoadingSpinner from '../Common/LoadingSpinner'; +import Modal from '../Common/Modal'; +import Slider from '../Slider'; +import TitleCard from '../TitleCard'; +import Transition from '../Transition'; + +const messages = defineMessages({ + overviewunavailable: 'Overview unavailable', + overview: 'Overview', + movies: 'Movies', + numberofmovies: 'Number of Movies: {count}', + requesting: 'Requesting…', + request: 'Request', + requestcollection: 'Request Collection', + requestswillbecreated: + 'The following titles will have requests created for them:', + requestSuccess: '{title} successfully requested!', +}); + +interface CollectionDetailsProps { + collection?: Collection; +} + +const CollectionDetails: React.FC = ({ + collection, +}) => { + const intl = useIntl(); + const router = useRouter(); + const { addToast } = useToasts(); + const { locale } = useContext(LanguageContext); + const [requestModal, setRequestModal] = useState(false); + const [isRequesting, setRequesting] = useState(false); + const { data, error, revalidate } = useSWR( + `/api/v1/collection/${router.query.collectionId}?language=${locale}`, + { + initialData: collection, + revalidateOnMount: true, + } + ); + + if (!data && !error) { + return ; + } + + if (!data) { + return ; + } + + const requestableParts = data.parts.filter( + (part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN + ); + + const requestBundle = async () => { + try { + setRequesting(true); + await Promise.all( + requestableParts.map(async (part) => { + await axios.post('/api/v1/request', { + mediaId: part.id, + mediaType: 'movie', + }); + }) + ); + + addToast( + + {intl.formatMessage(messages.requestSuccess, { + title: data?.name, + strong: function strong(msg) { + return {msg}; + }, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } catch (e) { + addToast('Something went wrong requesting the collection.', { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setRequesting(false); + setRequestModal(false); + revalidate(); + } + }; + + return ( +
+ + {data.name} - Overseerr + + + requestBundle()} + okText={ + isRequesting + ? intl.formatMessage(messages.requesting) + : intl.formatMessage(messages.request) + } + okDisabled={isRequesting} + okButtonType="primary" + onCancel={() => setRequestModal(false)} + title={intl.formatMessage(messages.requestcollection)} + iconSvg={ + + + + } + > +

{intl.formatMessage(messages.requestswillbecreated)}

+
    + {data.parts + .filter( + (part) => + !part.mediaInfo || + part.mediaInfo?.status === MediaStatus.UNKNOWN + ) + .map((part) => ( +
  • {part.title}
  • + ))} +
+
+
+
+
+ +
+
+
+ {data.parts.every( + (part) => part.mediaInfo?.status === MediaStatus.AVAILABLE + ) && ( + + {intl.formatMessage(globalMessages.available)} + + )} + {!data.parts.every( + (part) => part.mediaInfo?.status === MediaStatus.AVAILABLE + ) && + data.parts.some( + (part) => part.mediaInfo?.status === MediaStatus.AVAILABLE + ) && ( + + {intl.formatMessage(globalMessages.partiallyavailable)} + + )} +
+

{data.name}

+ + {intl.formatMessage(messages.numberofmovies, { + count: data.parts.length, + })} + +
+
+ {data.parts.some( + (part) => + !part.mediaInfo || part.mediaInfo?.status === MediaStatus.UNKNOWN + ) && ( + + )} +
+
+
+
+

+ {intl.formatMessage(messages.overview)} +

+

+ {data.overview + ? data.overview + : intl.formatMessage(messages.overviewunavailable)} +

+
+
+
+
+
+ {intl.formatMessage(messages.movies)} +
+
+
+ ( + + ))} + /> +
+
+ ); +}; + +export default CollectionDetails; diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx index 278ac7713..1f7672bc3 100644 --- a/src/components/Common/Button/index.tsx +++ b/src/components/Common/Button/index.tsx @@ -51,7 +51,7 @@ const Button: React.FC = ({ break; default: buttonStyle.push( - 'leading-5 font-medium rounded-md text-gray-200 bg-gray-500 hover:bg-gray-400 hover:text-white focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 disabled:opacity-50' + 'leading-5 font-medium rounded-md text-gray-200 bg-gray-500 hover:bg-gray-400 group-hover:bg-gray-400 hover:text-white group-hover:text-white focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 disabled:opacity-50' ); } diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index bb2c26f78..28601eb1e 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -438,6 +438,25 @@ const MovieDetails: React.FC = ({ movie }) => { )}
+ {data.collection && ( + + )}
{(data.voteCount > 0 || ratingData) && (
diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index 10f8e8e3c..58d891804 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -67,7 +67,11 @@ const MovieRequestModal: React.FC = ({ if (response.data) { if (onComplete) { - onComplete(response.data.media.status); + onComplete( + hasPermission(Permission.AUTO_APPROVE) + ? MediaStatus.PROCESSING + : MediaStatus.PENDING + ); } addToast( diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 15f71d52c..bb404d269 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import type { MediaType } from '../../../server/models/Search'; import Available from '../../assets/available.svg'; import Requested from '../../assets/requested.svg'; @@ -51,6 +51,10 @@ const TitleCard: React.FC = ({ year = year.slice(0, 4); } + useEffect(() => { + setCurrentStatus(status); + }, [status]); + const requestComplete = useCallback((newStatus: MediaStatus) => { setCurrentStatus(newStatus); setShowRequestModal(false); @@ -74,7 +78,7 @@ const TitleCard: React.FC = ({ onCancel={closeModal} />
= ({ role="link" tabIndex={0} > -
+
-
+
{mediaType === 'movie' ? intl.formatMessage(messages.movie) : intl.formatMessage(messages.tvshow)} @@ -107,7 +111,7 @@ const TitleCard: React.FC = ({
= ({ leaveFrom="opacity-100" leaveTo="opacity-0" > -
+
= ({
-
-
+
+
{year &&
{year}
}

@@ -191,11 +195,11 @@ const TitleCard: React.FC = ({ -
+
- + = ({ e.preventDefault(); setShowRequestModal(true); }} - className="w-full h-7 text-center text-white bg-indigo-500 rounded-sm ml-2 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150" + className="w-full ml-2 text-center text-white transition duration-150 ease-in-out bg-indigo-500 rounded-sm h-7 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700" > = ({ )} {currentStatus === MediaStatus.PENDING && (