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
|
||||
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,6 +1758,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/User'
|
||||
|
||||
/user/{userId}:
|
||||
|
@@ -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<TmdbMovieDetails>(
|
||||
`/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}`, {
|
||||
params: {
|
||||
language,
|
||||
append_to_response: 'credits,external_ids,keywords',
|
||||
append_to_response: 'credits,external_ids,keywords,videos',
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -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,
|
||||
|
@@ -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,
|
||||
|
@@ -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]);
|
||||
|
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
@@ -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<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}`
|
||||
@@ -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 ? (
|
||||
|
@@ -48,6 +48,7 @@ const messages = defineMessages({
|
||||
recommendations: 'Recommendations',
|
||||
similar: 'Similar Series',
|
||||
cancelrequest: 'Cancel Request',
|
||||
watchtrailer: 'Watch Trailer',
|
||||
available: 'Available',
|
||||
unavailable: 'Unavailable',
|
||||
request: 'Request',
|
||||
@@ -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;
|
||||
@@ -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)}
|
||||
>
|
||||
|
@@ -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",
|
||||
@@ -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",
|
||||
|
Reference in New Issue
Block a user