Skip to content

Commit

Permalink
feat: YouTube Movie/TV Trailers (#454)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
jayesh100 committed Dec 24, 2020
1 parent 329a814 commit e88dc83
Show file tree
Hide file tree
Showing 9 changed files with 143 additions and 6 deletions.
37 changes: 36 additions & 1 deletion overseerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -408,6 +438,10 @@ components:
$ref: '#/components/schemas/Genre'
homepage:
type: string
relatedVideos:
type: array
items:
$ref: '#/components/schemas/RelatedVideo'
originalLanguage:
type: string
originalTitle:
Expand Down Expand Up @@ -1724,7 +1758,8 @@ paths:
application/json:
schema:
type: array
$ref: '#/components/schemas/User'
items:
$ref: '#/components/schemas/User'

/user/{userId}:
get:
Expand Down
29 changes: 27 additions & 2 deletions server/api/themoviedb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -284,6 +301,11 @@ export interface TmdbTvDetails {
keywords: {
results: TmdbKeyword[];
};
videos: TmdbVideoResult;
}

export interface TmdbVideoResult {
results: TmdbVideo[];
}

export interface TmdbKeyword {
Expand Down Expand Up @@ -453,7 +475,10 @@ class TheMovieDb {
const response = await this.axios.get<TmdbMovieDetails>(
`/movie/${movieId}`,
{
params: { language, append_to_response: 'credits,external_ids' },
params: {
language,
append_to_response: 'credits,external_ids,videos',
},
}
);

Expand All @@ -474,7 +499,7 @@ class TheMovieDb {
const response = await this.axios.get<TmdbTvDetails>(`/tv/${tvId}`, {
params: {
language,
append_to_response: 'credits,external_ids,keywords',
append_to_response: 'credits,external_ids,keywords,videos',
},
});

Expand Down
19 changes: 19 additions & 0 deletions server/models/Movie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +40,7 @@ export interface MovieDetails {
originalTitle: string;
overview?: string;
popularity: number;
relatedVideos?: Video[];
posterPath?: string;
productionCompanies: ProductionCompany[];
productionCountries: {
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions server/models/Tv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ExternalIds,
mapExternalIds,
Keyword,
mapVideos,
} from './common';
import {
TmdbTvEpisodeResult,
Expand All @@ -16,6 +17,7 @@ import {
TmdbSeasonWithEpisodes,
} from '../api/themoviedb';
import type Media from '../entity/Media';
import { Video } from './Movie';

interface Episode {
id: number;
Expand Down Expand Up @@ -67,6 +69,7 @@ export interface TvDetails {
genres: Genre[];
homepage: string;
inProduction: boolean;
relatedVideos?: Video[];
languages: string[];
lastAirDate: string;
lastEpisodeToAir?: Episode;
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions server/models/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
11 changes: 8 additions & 3 deletions server/routes/movie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,27 @@ 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();

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' });
}
});
Expand Down
14 changes: 14 additions & 0 deletions src/components/MovieDetails/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const messages = defineMessages({
status: 'Status',
revenue: 'Revenue',
budget: 'Budget',
watchtrailer: 'Watch Trailer',
originallanguage: 'Original Language',
overview: 'Overview',
runtime: '{minutes} minutes',
Expand Down Expand Up @@ -121,6 +122,11 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ 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}`
Expand Down Expand Up @@ -244,10 +250,18 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</span>
</div>
<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?.status === MediaStatus.UNKNOWN) && (
<Button
buttonType="primary"
className="ml-2"
onClick={() => setShowRequestModal(true)}
>
{activeRequest ? (
Expand Down
14 changes: 14 additions & 0 deletions src/components/TvDetails/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const messages = defineMessages({
recommendations: 'Recommendations',
similar: 'Similar Series',
cancelrequest: 'Cancel Request',
watchtrailer: 'Watch Trailer',
available: 'Available',
unavailable: 'Unavailable',
request: 'Request',
Expand Down Expand Up @@ -130,6 +131,11 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
(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> => {
if (!activeRequests) {
return;
Expand Down Expand Up @@ -265,9 +271,17 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</span>
</div>
<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.status === MediaStatus.UNKNOWN) && (
<Button
className="ml-2"
buttonType="primary"
onClick={() => setShowRequestModal(true)}
>
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"components.MovieDetails.userrating": "User Rating",
"components.MovieDetails.viewfullcrew": "View Full Crew",
"components.MovieDetails.viewrequest": "View Request",
"components.MovieDetails.watchtrailer": "Watch Trailer",
"components.PersonDetails.appearsin": "Appears in",
"components.PersonDetails.ascharacter": "as {character}",
"components.PersonDetails.crewmember": "Crew Member",
Expand Down Expand Up @@ -322,6 +323,7 @@
"components.TvDetails.unavailable": "Unavailable",
"components.TvDetails.userrating": "User Rating",
"components.TvDetails.viewfullcrew": "View Full Crew",
"components.TvDetails.watchtrailer": "Watch Trailer",
"components.UserEdit.admin": "Admin",
"components.UserEdit.adminDescription": "Full administrator access. Bypasses all permission checks.",
"components.UserEdit.autoapprove": "Auto Approve",
Expand Down

0 comments on commit e88dc83

Please sign in to comment.