diff --git a/overseerr-api.yml b/overseerr-api.yml index f1ddfc654..47e175ec9 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -383,6 +383,36 @@ components: type: string name: type: string + RelatedVideo: + type: object + properties: + url: + type: string + example: https://www.youtube.com/watch?v=9qhL2_UxXM0/ + key: + type: string + example: 9qhL2_UxXM0 + name: + type: string + example: Trailer for some movie (1978) + size: + type: number + example: 1080 + type: + type: string + example: Trailer + enum: + - Clip + - Teaser + - Trailer + - Featurette + - Opening Credits + - Behind the Scenes + - Bloopers + site: + type: string + enum: + - 'YouTube' MovieDetails: type: object properties: @@ -408,6 +438,10 @@ components: $ref: '#/components/schemas/Genre' homepage: type: string + relatedVideos: + type: array + items: + $ref: '#/components/schemas/RelatedVideo' originalLanguage: type: string originalTitle: @@ -1724,7 +1758,8 @@ paths: application/json: schema: type: array - $ref: '#/components/schemas/User' + items: + $ref: '#/components/schemas/User' /user/{userId}: get: diff --git a/server/api/themoviedb.ts b/server/api/themoviedb.ts index 4cded2639..83fa3fad7 100644 --- a/server/api/themoviedb.ts +++ b/server/api/themoviedb.ts @@ -197,6 +197,23 @@ export interface TmdbMovieDetails { backdrop_path?: string; }; external_ids: TmdbExternalIds; + videos: TmdbVideoResult; +} + +export interface TmdbVideo { + id: string; + key: string; + name: string; + site: 'YouTube'; + size: number; + type: + | 'Clip' + | 'Teaser' + | 'Trailer' + | 'Featurette' + | 'Opening Credits' + | 'Behind the Scenes' + | 'Bloopers'; } export interface TmdbTvEpisodeResult { @@ -284,6 +301,11 @@ export interface TmdbTvDetails { keywords: { results: TmdbKeyword[]; }; + videos: TmdbVideoResult; +} + +export interface TmdbVideoResult { + results: TmdbVideo[]; } export interface TmdbKeyword { @@ -453,7 +475,10 @@ class TheMovieDb { const response = await this.axios.get( `/movie/${movieId}`, { - params: { language, append_to_response: 'credits,external_ids' }, + params: { + language, + append_to_response: 'credits,external_ids,videos', + }, } ); @@ -474,7 +499,7 @@ class TheMovieDb { const response = await this.axios.get(`/tv/${tvId}`, { params: { language, - append_to_response: 'credits,external_ids,keywords', + append_to_response: 'credits,external_ids,keywords,videos', }, }); diff --git a/server/models/Movie.ts b/server/models/Movie.ts index 8798dd130..a9367d73e 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -8,9 +8,26 @@ import { mapCrew, ExternalIds, mapExternalIds, + mapVideos, } from './common'; import Media from '../entity/Media'; +export interface Video { + url?: string; + site: 'YouTube'; + key: string; + name: string; + size: number; + type: + | 'Clip' + | 'Teaser' + | 'Trailer' + | 'Featurette' + | 'Opening Credits' + | 'Behind the Scenes' + | 'Bloopers'; +} + export interface MovieDetails { id: number; imdbId?: string; @@ -23,6 +40,7 @@ export interface MovieDetails { originalTitle: string; overview?: string; popularity: number; + relatedVideos?: Video[]; posterPath?: string; productionCompanies: ProductionCompany[]; productionCountries: { @@ -64,6 +82,7 @@ export const mapMovieDetails = ( adult: movie.adult, budget: movie.budget, genres: movie.genres, + relatedVideos: mapVideos(movie.videos), originalLanguage: movie.original_language, originalTitle: movie.original_title, popularity: movie.popularity, diff --git a/server/models/Tv.ts b/server/models/Tv.ts index 7c8759a9b..5ff2f631a 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -8,6 +8,7 @@ import { ExternalIds, mapExternalIds, Keyword, + mapVideos, } from './common'; import { TmdbTvEpisodeResult, @@ -16,6 +17,7 @@ import { TmdbSeasonWithEpisodes, } from '../api/themoviedb'; import type Media from '../entity/Media'; +import { Video } from './Movie'; interface Episode { id: number; @@ -67,6 +69,7 @@ export interface TvDetails { genres: Genre[]; homepage: string; inProduction: boolean; + relatedVideos?: Video[]; languages: string[]; lastAirDate: string; lastEpisodeToAir?: Episode; @@ -145,6 +148,7 @@ export const mapTvDetails = ( id: genre.id, name: genre.name, })), + relatedVideos: mapVideos(show.videos), homepage: show.homepage, id: show.id, inProduction: show.in_production, diff --git a/server/models/common.ts b/server/models/common.ts index 90945dc2e..733cf2dfc 100644 --- a/server/models/common.ts +++ b/server/models/common.ts @@ -2,8 +2,12 @@ import { TmdbCreditCast, TmdbCreditCrew, TmdbExternalIds, + TmdbVideo, + TmdbVideoResult, } from '../api/themoviedb'; +import { Video } from '../models/Movie'; + export interface ProductionCompany { id: number; logoPath?: string; @@ -84,3 +88,18 @@ export const mapExternalIds = (eids: TmdbExternalIds): ExternalIds => ({ tvrageId: eids.tvrage_id, twitterId: eids.twitter_id, }); + +export const mapVideos = (videoResult: TmdbVideoResult): Video[] => + videoResult?.results.map(({ key, name, size, type, site }: TmdbVideo) => ({ + site, + key, + name, + size, + type, + url: siteUrlCreator(site, key), + })); + +const siteUrlCreator = (site: Video['site'], key: string): string => + ({ + YouTube: `https://www.youtube.com/watch?v=${key}/`, + }[site]); diff --git a/server/routes/movie.ts b/server/routes/movie.ts index 492b22f7b..af4992ed3 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -4,6 +4,7 @@ import { mapMovieDetails } from '../models/Movie'; import { mapMovieResult } from '../models/Search'; import Media from '../entity/Media'; import RottenTomatoes from '../api/rottentomatoes'; +import logger from '../logger'; const movieRoutes = Router(); @@ -11,15 +12,19 @@ movieRoutes.get('/:id', async (req, res, next) => { const tmdb = new TheMovieDb(); try { - const movie = await tmdb.getMovie({ + const tmdbMovie = await tmdb.getMovie({ movieId: Number(req.params.id), language: req.query.language as string, }); - const media = await Media.getMedia(movie.id); + const media = await Media.getMedia(tmdbMovie.id); - return res.status(200).json(mapMovieDetails(movie, media)); + return res.status(200).json(mapMovieDetails(tmdbMovie, media)); } catch (e) { + logger.error('Something went wrong getting movie', { + label: 'Movie', + message: e.message, + }); return next({ status: 404, message: 'Movie does not exist' }); } }); diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 28601eb1e..22f24b1f8 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -46,6 +46,7 @@ const messages = defineMessages({ status: 'Status', revenue: 'Revenue', budget: 'Budget', + watchtrailer: 'Watch Trailer', originallanguage: 'Original Language', overview: 'Overview', runtime: '{minutes} minutes', @@ -121,6 +122,11 @@ const MovieDetails: React.FC = ({ movie }) => { (request) => request.status === MediaRequestStatus.PENDING ); + const trailerUrl = data.relatedVideos + ?.filter((r) => r.type === 'Trailer') + .sort((a, b) => a.size - b.size) + .pop()?.url; + const modifyRequest = async (type: 'approve' | 'decline') => { const response = await axios.get( `/api/v1/request/${activeRequest?.id}/${type}` @@ -244,10 +250,18 @@ const MovieDetails: React.FC = ({ movie }) => {
+ {trailerUrl && ( + + + + )} {(!data.mediaInfo || data.mediaInfo?.status === MediaStatus.UNKNOWN) && (
+ {trailerUrl && ( + + + + )} {(!data.mediaInfo || data.mediaInfo.status === MediaStatus.UNKNOWN) && (