feat: YouTube Movie/TV Trailers (#454)

* feat: Get Youtube trailers from TMDB API and show on Movie/TV details page

* docs(overseerr-api.yml): remove youtube trailer URL (unused) from OAS
pull/485/head
Jayesh 4 years ago committed by GitHub
parent 329a814a8f
commit e88dc83aeb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -383,6 +383,36 @@ components:
type: string type: string
name: name:
type: string 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: MovieDetails:
type: object type: object
properties: properties:
@ -408,6 +438,10 @@ components:
$ref: '#/components/schemas/Genre' $ref: '#/components/schemas/Genre'
homepage: homepage:
type: string type: string
relatedVideos:
type: array
items:
$ref: '#/components/schemas/RelatedVideo'
originalLanguage: originalLanguage:
type: string type: string
originalTitle: originalTitle:
@ -1724,6 +1758,7 @@ paths:
application/json: application/json:
schema: schema:
type: array type: array
items:
$ref: '#/components/schemas/User' $ref: '#/components/schemas/User'
/user/{userId}: /user/{userId}:

@ -197,6 +197,23 @@ export interface TmdbMovieDetails {
backdrop_path?: string; backdrop_path?: string;
}; };
external_ids: TmdbExternalIds; 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 { export interface TmdbTvEpisodeResult {
@ -284,6 +301,11 @@ export interface TmdbTvDetails {
keywords: { keywords: {
results: TmdbKeyword[]; results: TmdbKeyword[];
}; };
videos: TmdbVideoResult;
}
export interface TmdbVideoResult {
results: TmdbVideo[];
} }
export interface TmdbKeyword { export interface TmdbKeyword {
@ -453,7 +475,10 @@ class TheMovieDb {
const response = await this.axios.get<TmdbMovieDetails>( const response = await this.axios.get<TmdbMovieDetails>(
`/movie/${movieId}`, `/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<TmdbTvDetails>(`/tv/${tvId}`, { const response = await this.axios.get<TmdbTvDetails>(`/tv/${tvId}`, {
params: { params: {
language, language,
append_to_response: 'credits,external_ids,keywords', append_to_response: 'credits,external_ids,keywords,videos',
}, },
}); });

@ -8,9 +8,26 @@ import {
mapCrew, mapCrew,
ExternalIds, ExternalIds,
mapExternalIds, mapExternalIds,
mapVideos,
} from './common'; } from './common';
import Media from '../entity/Media'; 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 { export interface MovieDetails {
id: number; id: number;
imdbId?: string; imdbId?: string;
@ -23,6 +40,7 @@ export interface MovieDetails {
originalTitle: string; originalTitle: string;
overview?: string; overview?: string;
popularity: number; popularity: number;
relatedVideos?: Video[];
posterPath?: string; posterPath?: string;
productionCompanies: ProductionCompany[]; productionCompanies: ProductionCompany[];
productionCountries: { productionCountries: {
@ -64,6 +82,7 @@ export const mapMovieDetails = (
adult: movie.adult, adult: movie.adult,
budget: movie.budget, budget: movie.budget,
genres: movie.genres, genres: movie.genres,
relatedVideos: mapVideos(movie.videos),
originalLanguage: movie.original_language, originalLanguage: movie.original_language,
originalTitle: movie.original_title, originalTitle: movie.original_title,
popularity: movie.popularity, popularity: movie.popularity,

@ -8,6 +8,7 @@ import {
ExternalIds, ExternalIds,
mapExternalIds, mapExternalIds,
Keyword, Keyword,
mapVideos,
} from './common'; } from './common';
import { import {
TmdbTvEpisodeResult, TmdbTvEpisodeResult,
@ -16,6 +17,7 @@ import {
TmdbSeasonWithEpisodes, TmdbSeasonWithEpisodes,
} from '../api/themoviedb'; } from '../api/themoviedb';
import type Media from '../entity/Media'; import type Media from '../entity/Media';
import { Video } from './Movie';
interface Episode { interface Episode {
id: number; id: number;
@ -67,6 +69,7 @@ export interface TvDetails {
genres: Genre[]; genres: Genre[];
homepage: string; homepage: string;
inProduction: boolean; inProduction: boolean;
relatedVideos?: Video[];
languages: string[]; languages: string[];
lastAirDate: string; lastAirDate: string;
lastEpisodeToAir?: Episode; lastEpisodeToAir?: Episode;
@ -145,6 +148,7 @@ export const mapTvDetails = (
id: genre.id, id: genre.id,
name: genre.name, name: genre.name,
})), })),
relatedVideos: mapVideos(show.videos),
homepage: show.homepage, homepage: show.homepage,
id: show.id, id: show.id,
inProduction: show.in_production, inProduction: show.in_production,

@ -2,8 +2,12 @@ import {
TmdbCreditCast, TmdbCreditCast,
TmdbCreditCrew, TmdbCreditCrew,
TmdbExternalIds, TmdbExternalIds,
TmdbVideo,
TmdbVideoResult,
} from '../api/themoviedb'; } from '../api/themoviedb';
import { Video } from '../models/Movie';
export interface ProductionCompany { export interface ProductionCompany {
id: number; id: number;
logoPath?: string; logoPath?: string;
@ -84,3 +88,18 @@ export const mapExternalIds = (eids: TmdbExternalIds): ExternalIds => ({
tvrageId: eids.tvrage_id, tvrageId: eids.tvrage_id,
twitterId: eids.twitter_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]);

@ -4,6 +4,7 @@ import { mapMovieDetails } from '../models/Movie';
import { mapMovieResult } from '../models/Search'; import { mapMovieResult } from '../models/Search';
import Media from '../entity/Media'; import Media from '../entity/Media';
import RottenTomatoes from '../api/rottentomatoes'; import RottenTomatoes from '../api/rottentomatoes';
import logger from '../logger';
const movieRoutes = Router(); const movieRoutes = Router();
@ -11,15 +12,19 @@ movieRoutes.get('/:id', async (req, res, next) => {
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
try { try {
const movie = await tmdb.getMovie({ const tmdbMovie = await tmdb.getMovie({
movieId: Number(req.params.id), movieId: Number(req.params.id),
language: req.query.language as string, 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) { } catch (e) {
logger.error('Something went wrong getting movie', {
label: 'Movie',
message: e.message,
});
return next({ status: 404, message: 'Movie does not exist' }); return next({ status: 404, message: 'Movie does not exist' });
} }
}); });

@ -46,6 +46,7 @@ const messages = defineMessages({
status: 'Status', status: 'Status',
revenue: 'Revenue', revenue: 'Revenue',
budget: 'Budget', budget: 'Budget',
watchtrailer: 'Watch Trailer',
originallanguage: 'Original Language', originallanguage: 'Original Language',
overview: 'Overview', overview: 'Overview',
runtime: '{minutes} minutes', runtime: '{minutes} minutes',
@ -121,6 +122,11 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
(request) => request.status === MediaRequestStatus.PENDING (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 modifyRequest = async (type: 'approve' | 'decline') => {
const response = await axios.get( const response = await axios.get(
`/api/v1/request/${activeRequest?.id}/${type}` `/api/v1/request/${activeRequest?.id}/${type}`
@ -244,10 +250,18 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</span> </span>
</div> </div>
<div className="flex justify-end flex-1 mt-4 md:mt-0"> <div className="flex justify-end flex-1 mt-4 md:mt-0">
{trailerUrl && (
<a href={trailerUrl} target={'_blank'} rel="noreferrer">
<Button buttonType="ghost">
<FormattedMessage {...messages.watchtrailer} />
</Button>
</a>
)}
{(!data.mediaInfo || {(!data.mediaInfo ||
data.mediaInfo?.status === MediaStatus.UNKNOWN) && ( data.mediaInfo?.status === MediaStatus.UNKNOWN) && (
<Button <Button
buttonType="primary" buttonType="primary"
className="ml-2"
onClick={() => setShowRequestModal(true)} onClick={() => setShowRequestModal(true)}
> >
{activeRequest ? ( {activeRequest ? (

@ -48,6 +48,7 @@ const messages = defineMessages({
recommendations: 'Recommendations', recommendations: 'Recommendations',
similar: 'Similar Series', similar: 'Similar Series',
cancelrequest: 'Cancel Request', cancelrequest: 'Cancel Request',
watchtrailer: 'Watch Trailer',
available: 'Available', available: 'Available',
unavailable: 'Unavailable', unavailable: 'Unavailable',
request: 'Request', request: 'Request',
@ -130,6 +131,11 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
(request) => request.status === MediaRequestStatus.PENDING (request) => request.status === MediaRequestStatus.PENDING
); );
const trailerUrl = data.relatedVideos
?.filter((r) => r.type === 'Trailer')
.sort((a, b) => a.size - b.size)
.pop()?.url;
const modifyRequests = async (type: 'approve' | 'decline'): Promise<void> => { const modifyRequests = async (type: 'approve' | 'decline'): Promise<void> => {
if (!activeRequests) { if (!activeRequests) {
return; return;
@ -265,9 +271,17 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</span> </span>
</div> </div>
<div className="flex justify-end flex-1 mt-4 md:mt-0"> <div className="flex justify-end flex-1 mt-4 md:mt-0">
{trailerUrl && (
<a href={trailerUrl} target="_blank" rel="noreferrer">
<Button buttonType="ghost">
<FormattedMessage {...messages.watchtrailer} />
</Button>
</a>
)}
{(!data.mediaInfo || {(!data.mediaInfo ||
data.mediaInfo.status === MediaStatus.UNKNOWN) && ( data.mediaInfo.status === MediaStatus.UNKNOWN) && (
<Button <Button
className="ml-2"
buttonType="primary" buttonType="primary"
onClick={() => setShowRequestModal(true)} onClick={() => setShowRequestModal(true)}
> >

@ -58,6 +58,7 @@
"components.MovieDetails.userrating": "User Rating", "components.MovieDetails.userrating": "User Rating",
"components.MovieDetails.viewfullcrew": "View Full Crew", "components.MovieDetails.viewfullcrew": "View Full Crew",
"components.MovieDetails.viewrequest": "View Request", "components.MovieDetails.viewrequest": "View Request",
"components.MovieDetails.watchtrailer": "Watch Trailer",
"components.PersonDetails.appearsin": "Appears in", "components.PersonDetails.appearsin": "Appears in",
"components.PersonDetails.ascharacter": "as {character}", "components.PersonDetails.ascharacter": "as {character}",
"components.PersonDetails.crewmember": "Crew Member", "components.PersonDetails.crewmember": "Crew Member",
@ -322,6 +323,7 @@
"components.TvDetails.unavailable": "Unavailable", "components.TvDetails.unavailable": "Unavailable",
"components.TvDetails.userrating": "User Rating", "components.TvDetails.userrating": "User Rating",
"components.TvDetails.viewfullcrew": "View Full Crew", "components.TvDetails.viewfullcrew": "View Full Crew",
"components.TvDetails.watchtrailer": "Watch Trailer",
"components.UserEdit.admin": "Admin", "components.UserEdit.admin": "Admin",
"components.UserEdit.adminDescription": "Full administrator access. Bypasses all permission checks.", "components.UserEdit.adminDescription": "Full administrator access. Bypasses all permission checks.",
"components.UserEdit.autoapprove": "Auto Approve", "components.UserEdit.autoapprove": "Auto Approve",

Loading…
Cancel
Save