mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
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
This commit is contained in:
@@ -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",
|
||||||
|
Reference in New Issue
Block a user