mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat: list streaming providers on movie/TV detail pages (#1778)
* feat: list streaming providers on movie/TV detail pages * fix(ui): add margin to media fact value
This commit is contained in:
@@ -768,6 +768,10 @@ components:
|
|||||||
$ref: '#/components/schemas/ExternalIds'
|
$ref: '#/components/schemas/ExternalIds'
|
||||||
mediaInfo:
|
mediaInfo:
|
||||||
$ref: '#/components/schemas/MediaInfo'
|
$ref: '#/components/schemas/MediaInfo'
|
||||||
|
watchProviders:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/WatchProviders'
|
||||||
Episode:
|
Episode:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -942,6 +946,10 @@ components:
|
|||||||
$ref: '#/components/schemas/Keyword'
|
$ref: '#/components/schemas/Keyword'
|
||||||
mediaInfo:
|
mediaInfo:
|
||||||
$ref: '#/components/schemas/MediaInfo'
|
$ref: '#/components/schemas/MediaInfo'
|
||||||
|
watchProviders:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/WatchProviders'
|
||||||
MediaRequest:
|
MediaRequest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1631,6 +1639,33 @@ components:
|
|||||||
type: number
|
type: number
|
||||||
webpush:
|
webpush:
|
||||||
type: number
|
type: number
|
||||||
|
WatchProviders:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
iso_3166_1:
|
||||||
|
type: string
|
||||||
|
link:
|
||||||
|
type: string
|
||||||
|
buy:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/WatchProviderDetails'
|
||||||
|
flatrate:
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/WatchProviderDetails'
|
||||||
|
WatchProviderDetails:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
displayPriority:
|
||||||
|
type: number
|
||||||
|
logoPath:
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
type: number
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
cookieAuth:
|
cookieAuth:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
|
@@ -170,7 +170,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
language,
|
language,
|
||||||
append_to_response: 'credits,external_ids,videos,release_dates',
|
append_to_response:
|
||||||
|
'credits,external_ids,videos,release_dates,watch/providers',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
43200
|
43200
|
||||||
@@ -196,7 +197,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
params: {
|
params: {
|
||||||
language,
|
language,
|
||||||
append_to_response:
|
append_to_response:
|
||||||
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings',
|
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
43200
|
43200
|
||||||
|
@@ -166,6 +166,10 @@ export interface TmdbMovieDetails {
|
|||||||
};
|
};
|
||||||
external_ids: TmdbExternalIds;
|
external_ids: TmdbExternalIds;
|
||||||
videos: TmdbVideoResult;
|
videos: TmdbVideoResult;
|
||||||
|
'watch/providers'?: {
|
||||||
|
id: number;
|
||||||
|
results?: { [iso_3166_1: string]: TmdbWatchProviders };
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbVideo {
|
export interface TmdbVideo {
|
||||||
@@ -269,6 +273,10 @@ export interface TmdbTvDetails {
|
|||||||
results: TmdbKeyword[];
|
results: TmdbKeyword[];
|
||||||
};
|
};
|
||||||
videos: TmdbVideoResult;
|
videos: TmdbVideoResult;
|
||||||
|
'watch/providers'?: {
|
||||||
|
id: number;
|
||||||
|
results?: { [iso_3166_1: string]: TmdbWatchProviders };
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbVideoResult {
|
export interface TmdbVideoResult {
|
||||||
@@ -401,3 +409,16 @@ export interface TmdbNetwork {
|
|||||||
logo_path?: string;
|
logo_path?: string;
|
||||||
origin_country?: string;
|
origin_country?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TmdbWatchProviders {
|
||||||
|
link?: string;
|
||||||
|
buy?: TmdbWatchProviderDetails[];
|
||||||
|
flatrate?: TmdbWatchProviderDetails[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmdbWatchProviderDetails {
|
||||||
|
display_priority?: number;
|
||||||
|
logo_path?: string;
|
||||||
|
provider_id: number;
|
||||||
|
provider_name: string;
|
||||||
|
}
|
||||||
|
@@ -3,18 +3,20 @@ import type {
|
|||||||
TmdbMovieReleaseResult,
|
TmdbMovieReleaseResult,
|
||||||
TmdbProductionCompany,
|
TmdbProductionCompany,
|
||||||
} from '../api/themoviedb/interfaces';
|
} from '../api/themoviedb/interfaces';
|
||||||
|
import Media from '../entity/Media';
|
||||||
import {
|
import {
|
||||||
ProductionCompany,
|
|
||||||
Genre,
|
|
||||||
Cast,
|
Cast,
|
||||||
Crew,
|
Crew,
|
||||||
|
ExternalIds,
|
||||||
|
Genre,
|
||||||
mapCast,
|
mapCast,
|
||||||
mapCrew,
|
mapCrew,
|
||||||
ExternalIds,
|
|
||||||
mapExternalIds,
|
mapExternalIds,
|
||||||
mapVideos,
|
mapVideos,
|
||||||
|
mapWatchProviders,
|
||||||
|
ProductionCompany,
|
||||||
|
WatchProviders,
|
||||||
} from './common';
|
} from './common';
|
||||||
import Media from '../entity/Media';
|
|
||||||
|
|
||||||
export interface Video {
|
export interface Video {
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -78,6 +80,7 @@ export interface MovieDetails {
|
|||||||
mediaInfo?: Media;
|
mediaInfo?: Media;
|
||||||
externalIds: ExternalIds;
|
externalIds: ExternalIds;
|
||||||
plexUrl?: string;
|
plexUrl?: string;
|
||||||
|
watchProviders?: WatchProviders[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mapProductionCompany = (
|
export const mapProductionCompany = (
|
||||||
@@ -136,4 +139,5 @@ export const mapMovieDetails = (
|
|||||||
: undefined,
|
: undefined,
|
||||||
externalIds: mapExternalIds(movie.external_ids),
|
externalIds: mapExternalIds(movie.external_ids),
|
||||||
mediaInfo: media,
|
mediaInfo: media,
|
||||||
|
watchProviders: mapWatchProviders(movie['watch/providers']?.results ?? {}),
|
||||||
});
|
});
|
||||||
|
@@ -1,25 +1,27 @@
|
|||||||
import {
|
|
||||||
Genre,
|
|
||||||
ProductionCompany,
|
|
||||||
Cast,
|
|
||||||
Crew,
|
|
||||||
mapAggregateCast,
|
|
||||||
mapCrew,
|
|
||||||
ExternalIds,
|
|
||||||
mapExternalIds,
|
|
||||||
Keyword,
|
|
||||||
mapVideos,
|
|
||||||
TvNetwork,
|
|
||||||
} from './common';
|
|
||||||
import type {
|
import type {
|
||||||
TmdbTvEpisodeResult,
|
|
||||||
TmdbTvSeasonResult,
|
|
||||||
TmdbTvDetails,
|
|
||||||
TmdbSeasonWithEpisodes,
|
|
||||||
TmdbTvRatingResult,
|
|
||||||
TmdbNetwork,
|
TmdbNetwork,
|
||||||
|
TmdbSeasonWithEpisodes,
|
||||||
|
TmdbTvDetails,
|
||||||
|
TmdbTvEpisodeResult,
|
||||||
|
TmdbTvRatingResult,
|
||||||
|
TmdbTvSeasonResult,
|
||||||
} from '../api/themoviedb/interfaces';
|
} from '../api/themoviedb/interfaces';
|
||||||
import type Media from '../entity/Media';
|
import type Media from '../entity/Media';
|
||||||
|
import {
|
||||||
|
Cast,
|
||||||
|
Crew,
|
||||||
|
ExternalIds,
|
||||||
|
Genre,
|
||||||
|
Keyword,
|
||||||
|
mapAggregateCast,
|
||||||
|
mapCrew,
|
||||||
|
mapExternalIds,
|
||||||
|
mapVideos,
|
||||||
|
mapWatchProviders,
|
||||||
|
ProductionCompany,
|
||||||
|
TvNetwork,
|
||||||
|
WatchProviders,
|
||||||
|
} from './common';
|
||||||
import { Video } from './Movie';
|
import { Video } from './Movie';
|
||||||
|
|
||||||
interface Episode {
|
interface Episode {
|
||||||
@@ -102,6 +104,7 @@ export interface TvDetails {
|
|||||||
externalIds: ExternalIds;
|
externalIds: ExternalIds;
|
||||||
keywords: Keyword[];
|
keywords: Keyword[];
|
||||||
mediaInfo?: Media;
|
mediaInfo?: Media;
|
||||||
|
watchProviders?: WatchProviders[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
|
const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
|
||||||
@@ -213,4 +216,5 @@ export const mapTvDetails = (
|
|||||||
name: keyword.name,
|
name: keyword.name,
|
||||||
})),
|
})),
|
||||||
mediaInfo: media,
|
mediaInfo: media,
|
||||||
|
watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}),
|
||||||
});
|
});
|
||||||
|
@@ -1,12 +1,13 @@
|
|||||||
import type {
|
import type {
|
||||||
TmdbCreditCast,
|
|
||||||
TmdbAggregateCreditCast,
|
TmdbAggregateCreditCast,
|
||||||
|
TmdbCreditCast,
|
||||||
TmdbCreditCrew,
|
TmdbCreditCrew,
|
||||||
TmdbExternalIds,
|
TmdbExternalIds,
|
||||||
TmdbVideo,
|
TmdbVideo,
|
||||||
TmdbVideoResult,
|
TmdbVideoResult,
|
||||||
|
TmdbWatchProviderDetails,
|
||||||
|
TmdbWatchProviders,
|
||||||
} from '../api/themoviedb/interfaces';
|
} from '../api/themoviedb/interfaces';
|
||||||
|
|
||||||
import { Video } from '../models/Movie';
|
import { Video } from '../models/Movie';
|
||||||
|
|
||||||
export interface ProductionCompany {
|
export interface ProductionCompany {
|
||||||
@@ -70,6 +71,20 @@ export interface ExternalIds {
|
|||||||
twitterId?: string;
|
twitterId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WatchProviders {
|
||||||
|
iso_3166_1: string;
|
||||||
|
link?: string;
|
||||||
|
buy?: WatchProviderDetails[];
|
||||||
|
flatrate?: WatchProviderDetails[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchProviderDetails {
|
||||||
|
displayPriority?: number;
|
||||||
|
logoPath?: string;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const mapCast = (person: TmdbCreditCast): Cast => ({
|
export const mapCast = (person: TmdbCreditCast): Cast => ({
|
||||||
castId: person.cast_id,
|
castId: person.cast_id,
|
||||||
character: person.character,
|
character: person.character,
|
||||||
@@ -124,7 +139,33 @@ export const mapVideos = (videoResult: TmdbVideoResult): Video[] =>
|
|||||||
url: siteUrlCreator(site, key),
|
url: siteUrlCreator(site, key),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const mapWatchProviders = (watchProvidersResult: {
|
||||||
|
[iso_3166_1: string]: TmdbWatchProviders;
|
||||||
|
}): WatchProviders[] =>
|
||||||
|
Object.entries(watchProvidersResult).map(
|
||||||
|
([iso_3166_1, provider]) =>
|
||||||
|
({
|
||||||
|
iso_3166_1,
|
||||||
|
link: provider.link,
|
||||||
|
buy: mapWatchProviderDetails(provider.buy ?? []),
|
||||||
|
flatrate: mapWatchProviderDetails(provider.flatrate ?? []),
|
||||||
|
} as WatchProviders)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const mapWatchProviderDetails = (
|
||||||
|
watchProviderDetails: TmdbWatchProviderDetails[]
|
||||||
|
): WatchProviderDetails[] =>
|
||||||
|
watchProviderDetails.map(
|
||||||
|
(provider) =>
|
||||||
|
({
|
||||||
|
displayPriority: provider.display_priority,
|
||||||
|
logoPath: provider.logo_path,
|
||||||
|
id: provider.provider_id,
|
||||||
|
name: provider.provider_name,
|
||||||
|
} as WatchProviderDetails)
|
||||||
|
);
|
||||||
|
|
||||||
const siteUrlCreator = (site: Video['site'], key: string): string =>
|
const siteUrlCreator = (site: Video['site'], key: string): string =>
|
||||||
({
|
({
|
||||||
YouTube: `https://www.youtube.com/watch?v=${key}/`,
|
YouTube: `https://www.youtube.com/watch?v=${key}`,
|
||||||
}[site]);
|
}[site]);
|
||||||
|
@@ -77,6 +77,7 @@ const messages = defineMessages({
|
|||||||
mark4kavailable: 'Mark as Available in 4K',
|
mark4kavailable: 'Mark as Available in 4K',
|
||||||
showmore: 'Show More',
|
showmore: 'Show More',
|
||||||
showless: 'Show Less',
|
showless: 'Show Less',
|
||||||
|
streamingproviders: 'Currently Streaming On',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface MovieDetailsProps {
|
interface MovieDetailsProps {
|
||||||
@@ -220,6 +221,10 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const streamingProviders =
|
||||||
|
data?.watchProviders?.find((provider) => provider.iso_3166_1 === region)
|
||||||
|
?.flatrate ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="media-page"
|
className="media-page"
|
||||||
@@ -675,6 +680,20 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!!streamingProviders.length && (
|
||||||
|
<div className="media-fact">
|
||||||
|
<span>{intl.formatMessage(messages.streamingproviders)}</span>
|
||||||
|
<span className="media-fact-value">
|
||||||
|
{streamingProviders.map((p) => {
|
||||||
|
return (
|
||||||
|
<span className="block" key={`provider-${p.id}`}>
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="media-fact">
|
<div className="media-fact">
|
||||||
<ExternalLinkBlock
|
<ExternalLinkBlock
|
||||||
mediaType="movie"
|
mediaType="movie"
|
||||||
|
@@ -80,6 +80,7 @@ const messages = defineMessages({
|
|||||||
seasons: '{seasonCount, plural, one {# Season} other {# Seasons}}',
|
seasons: '{seasonCount, plural, one {# Season} other {# Seasons}}',
|
||||||
episodeRuntime: 'Episode Runtime',
|
episodeRuntime: 'Episode Runtime',
|
||||||
episodeRuntimeMinutes: '{runtime} minutes',
|
episodeRuntimeMinutes: '{runtime} minutes',
|
||||||
|
streamingproviders: 'Currently Streaming On',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface TvDetailsProps {
|
interface TvDetailsProps {
|
||||||
@@ -235,6 +236,10 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
|||||||
) ?? []
|
) ?? []
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
|
const streamingProviders =
|
||||||
|
data?.watchProviders?.find((provider) => provider.iso_3166_1 === region)
|
||||||
|
?.flatrate ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="media-page"
|
className="media-page"
|
||||||
@@ -663,6 +668,20 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!!streamingProviders.length && (
|
||||||
|
<div className="media-fact">
|
||||||
|
<span>{intl.formatMessage(messages.streamingproviders)}</span>
|
||||||
|
<span className="media-fact-value">
|
||||||
|
{streamingProviders.map((p) => {
|
||||||
|
return (
|
||||||
|
<span className="block" key={`provider-${p.id}`}>
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="media-fact">
|
<div className="media-fact">
|
||||||
<ExternalLinkBlock
|
<ExternalLinkBlock
|
||||||
mediaType="tv"
|
mediaType="tv"
|
||||||
|
@@ -86,6 +86,7 @@
|
|||||||
"components.MovieDetails.showless": "Show Less",
|
"components.MovieDetails.showless": "Show Less",
|
||||||
"components.MovieDetails.showmore": "Show More",
|
"components.MovieDetails.showmore": "Show More",
|
||||||
"components.MovieDetails.similar": "Similar Titles",
|
"components.MovieDetails.similar": "Similar Titles",
|
||||||
|
"components.MovieDetails.streamingproviders": "Currently Streaming On",
|
||||||
"components.MovieDetails.studio": "{studioCount, plural, one {Studio} other {Studios}}",
|
"components.MovieDetails.studio": "{studioCount, plural, one {Studio} other {Studios}}",
|
||||||
"components.MovieDetails.viewfullcrew": "View Full Crew",
|
"components.MovieDetails.viewfullcrew": "View Full Crew",
|
||||||
"components.MovieDetails.watchtrailer": "Watch Trailer",
|
"components.MovieDetails.watchtrailer": "Watch Trailer",
|
||||||
@@ -700,6 +701,7 @@
|
|||||||
"components.TvDetails.seasons": "{seasonCount, plural, one {# Season} other {# Seasons}}",
|
"components.TvDetails.seasons": "{seasonCount, plural, one {# Season} other {# Seasons}}",
|
||||||
"components.TvDetails.showtype": "Series Type",
|
"components.TvDetails.showtype": "Series Type",
|
||||||
"components.TvDetails.similar": "Similar Series",
|
"components.TvDetails.similar": "Similar Series",
|
||||||
|
"components.TvDetails.streamingproviders": "Currently Streaming On",
|
||||||
"components.TvDetails.viewfullcrew": "View Full Crew",
|
"components.TvDetails.viewfullcrew": "View Full Crew",
|
||||||
"components.TvDetails.watchtrailer": "Watch Trailer",
|
"components.TvDetails.watchtrailer": "Watch Trailer",
|
||||||
"components.UserList.accounttype": "Type",
|
"components.UserList.accounttype": "Type",
|
||||||
|
@@ -178,7 +178,7 @@ a.crew-name,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.media-fact-value {
|
.media-fact-value {
|
||||||
@apply text-sm font-normal text-right text-gray-400;
|
@apply ml-2 text-sm font-normal text-right text-gray-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-ratings {
|
.media-ratings {
|
||||||
|
Reference in New Issue
Block a user