From 20a9337dc322d3a45760966cd529ad53fdefc8c3 Mon Sep 17 00:00:00 2001 From: Captain Jack Sparrow <163903675+sussy-code@users.noreply.github.com> Date: Wed, 19 Jun 2024 18:22:27 +0000 Subject: [PATCH] Got it working / looking nice --- src/backend/metadata/types/tmdb.ts | 580 +++++++++--------- src/components/media/ModalEpisodeSelector.tsx | 47 +- src/components/media/PopupModal.tsx | 13 +- src/pages/parts/home/BookmarksPart.tsx | 59 +- 4 files changed, 349 insertions(+), 350 deletions(-) diff --git a/src/backend/metadata/types/tmdb.ts b/src/backend/metadata/types/tmdb.ts index a265b051..5d082f55 100644 --- a/src/backend/metadata/types/tmdb.ts +++ b/src/backend/metadata/types/tmdb.ts @@ -1,309 +1,289 @@ -import slugify from "slugify"; - -import { conf } from "@/setup/config"; -import { MediaItem } from "@/utils/mediaTypes"; - -import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types/mw"; -import { - ExternalIdMovieSearchResult, - TMDBContentTypes, - TMDBEpisodeShort, - TMDBMediaResult, - TMDBMovieData, - TMDBMovieSearchResult, - TMDBSearchResult, - TMDBSeason, - TMDBSeasonMetaResult, - TMDBShowData, - TMDBShowSearchResult, -} from "./types/tmdb"; -import { mwFetch } from "../helpers/fetch"; - -export function mediaTypeToTMDB(type: MWMediaType): TMDBContentTypes { - if (type === MWMediaType.MOVIE) return TMDBContentTypes.MOVIE; - if (type === MWMediaType.SERIES) return TMDBContentTypes.TV; - throw new Error("unsupported type"); +export enum TMDBContentTypes { + MOVIE = "movie", + TV = "tv", } -export function mediaItemTypeToMediaType(type: MediaItem["type"]): MWMediaType { - if (type === "movie") return MWMediaType.MOVIE; - if (type === "show") return MWMediaType.SERIES; - throw new Error("unsupported type"); -} - -export function TMDBMediaToMediaType(type: TMDBContentTypes): MWMediaType { - if (type === TMDBContentTypes.MOVIE) return MWMediaType.MOVIE; - if (type === TMDBContentTypes.TV) return MWMediaType.SERIES; - throw new Error("unsupported type"); -} - -export function TMDBMediaToMediaItemType( - type: TMDBContentTypes, -): MediaItem["type"] { - if (type === TMDBContentTypes.MOVIE) return "movie"; - if (type === TMDBContentTypes.TV) return "show"; - throw new Error("unsupported type"); -} - -export function formatTMDBMeta( - media: TMDBMediaResult, - season?: TMDBSeasonMetaResult, -): MWMediaMeta { - const type = TMDBMediaToMediaType(media.object_type); - let seasons: undefined | MWSeasonMeta[]; - if (type === MWMediaType.SERIES) { - seasons = media.seasons - ?.sort((a, b) => a.season_number - b.season_number) - .map( - (v): MWSeasonMeta => ({ - title: v.title, - id: v.id.toString(), - number: v.season_number, - }), - ); - } - - return { - title: media.title, - id: media.id.toString(), - year: media.original_release_date?.getFullYear()?.toString(), - poster: media.poster, - type, - seasons: seasons as any, - seasonData: season - ? { - id: season.id.toString(), - number: season.season_number, - title: season.title, - episodes: season.episodes - .sort((a, b) => a.episode_number - b.episode_number) - .map((v) => ({ - id: v.id.toString(), - number: v.episode_number, - title: v.title, - air_date: v.air_date, - })), - } - : (undefined as any), - }; -} - -export function formatTMDBMetaToMediaItem(media: TMDBMediaResult): MediaItem { - const type = TMDBMediaToMediaItemType(media.object_type); - - // Define the basic structure of MediaItem - const mediaItem: MediaItem = { - title: media.title, - id: media.id.toString(), - year: media.original_release_date?.getFullYear() ?? 0, - release_date: media.original_release_date, - poster: media.poster, - type, - seasons: undefined, - }; - - // If it's a TV show, include the seasons information - if (type === "show") { - const seasons = media.seasons?.map((season) => ({ - title: season.title, - id: season.id.toString(), - number: season.season_number, - })); - mediaItem.seasons = seasons as MWSeasonMeta[]; - } - - return mediaItem; -} - -export function TMDBIdToUrlId( - type: MWMediaType, - tmdbId: string, - title: string, -) { - return [ - "tmdb", - mediaTypeToTMDB(type), - tmdbId, - slugify(title, { lower: true, strict: true }), - ].join("-"); -} - -export function TMDBMediaToId(media: MWMediaMeta): string { - return TMDBIdToUrlId(media.type, media.id, media.title); -} - -export function mediaItemToId(media: MediaItem): string { - return TMDBIdToUrlId( - mediaItemTypeToMediaType(media.type), - media.id, - media.title, - ); -} - -export function decodeTMDBId( - paramId: string, -): { id: string; type: MWMediaType } | null { - const [prefix, type, id] = paramId.split("-", 3); - if (prefix !== "tmdb") return null; - let mediaType; - try { - mediaType = TMDBMediaToMediaType(type as TMDBContentTypes); - } catch { - return null; - } - return { - type: mediaType, - id, - }; -} - -const tmdbBaseUrl1 = "https://api.themoviedb.org/3"; -const tmdbBaseUrl2 = "https://api.tmdb.org/3"; - -const apiKey = conf().TMDB_READ_API_KEY; - -const tmdbHeaders = { - accept: "application/json", - Authorization: `Bearer ${apiKey}`, +export type TMDBSeasonShort = { + title: string; + id: number; + season_number: number; }; -function abortOnTimeout(timeout: number): AbortSignal { - const controller = new AbortController(); - setTimeout(() => controller.abort(), timeout); - return controller.signal; -} +export type TMDBEpisodeShort = { + title: string; + id: number; + episode_number: number; + air_date: string; +}; -export async function get(url: string, params?: object): Promise { - if (!apiKey) throw new Error("TMDB API key not set"); - try { - return await mwFetch(encodeURI(url), { - headers: tmdbHeaders, - baseURL: tmdbBaseUrl1, - params: { - ...params, - }, - signal: abortOnTimeout(5000), - }); - } catch (err) { - return mwFetch(encodeURI(url), { - headers: tmdbHeaders, - baseURL: tmdbBaseUrl2, - params: { - ...params, - }, - signal: abortOnTimeout(30000), - }); - } -} +export type TMDBMediaResult = { + title: string; + poster?: string; + id: number; + original_release_date?: Date; + object_type: TMDBContentTypes; + seasons?: TMDBSeasonShort[]; +}; -export async function multiSearch( - query: string, -): Promise<(TMDBMovieSearchResult | TMDBShowSearchResult)[]> { - const data = await get("search/multi", { - query, - include_adult: false, - language: "en-US", - page: 1, - }); - // filter out results that aren't movies or shows - const results = data.results.filter( - (r) => - r.media_type === TMDBContentTypes.MOVIE || - r.media_type === TMDBContentTypes.TV, - ); - return results; -} +export type TMDBSeasonMetaResult = { + title: string; + id: string; + season_number: number; + episodes: TMDBEpisodeShort[]; +}; -export async function generateQuickSearchMediaUrl( - query: string, -): Promise { - const data = await multiSearch(query); - if (data.length === 0) return undefined; - const result = data[0]; - const title = - result.media_type === TMDBContentTypes.MOVIE ? result.title : result.name; - - return `/media/${TMDBIdToUrlId( - TMDBMediaToMediaType(result.media_type), - result.id.toString(), - title, - )}`; -} - -// Conditional type which for inferring the return type based on the content type -type MediaDetailReturn = - T extends TMDBContentTypes.MOVIE - ? TMDBMovieData - : T extends TMDBContentTypes.TV - ? TMDBShowData - : never; - -export function getMediaDetails< - T extends TMDBContentTypes, - TReturn = MediaDetailReturn, ->(id: string, type: T): Promise { - if (type === TMDBContentTypes.MOVIE) { - return get(`/movie/${id}`, { append_to_response: "external_ids" }); - } - if (type === TMDBContentTypes.TV) { - return get(`/tv/${id}`, { append_to_response: "external_ids" }); - } - throw new Error("Invalid media type"); -} - -export function getMediaPoster(posterPath: string | null): string | undefined { - if (posterPath) return `https://image.tmdb.org/t/p/w342/${posterPath}`; -} - -export async function getEpisodes( - id: string, - season: number, -): Promise { - const data = await get(`/tv/${id}/season/${season}`); - return data.episodes.map((e) => ({ - id: e.id, - episode_number: e.episode_number, - title: e.name, - air_date: e.air_date, - })); -} - -export async function getMovieFromExternalId( - imdbId: string, -): Promise { - const data = await get(`/find/${imdbId}`, { - external_source: "imdb_id", - }); - - const movie = data.movie_results[0]; - if (!movie) return undefined; - - return movie.id.toString(); -} - -export function formatTMDBSearchResult( - result: TMDBMovieSearchResult | TMDBShowSearchResult, - mediatype: TMDBContentTypes, -): TMDBMediaResult { - const type = TMDBMediaToMediaType(mediatype); - if (type === MWMediaType.SERIES) { - const show = result as TMDBShowSearchResult; - return { - title: show.name, - poster: getMediaPoster(show.poster_path), - id: show.id, - original_release_date: new Date(show.first_air_date), - object_type: mediatype, - }; - } - - const movie = result as TMDBMovieSearchResult; - - return { - title: movie.title, - poster: getMediaPoster(movie.poster_path), - id: movie.id, - original_release_date: new Date(movie.release_date), - object_type: mediatype, +export interface TMDBShowData { + adult: boolean; + backdrop_path: string | null; + created_by: { + id: number; + credit_id: string; + name: string; + gender: number; + profile_path: string | null; + }[]; + episode_run_time: number[]; + first_air_date: string; + genres: { + id: number; + name: string; + }[]; + homepage: string; + id: number; + in_production: boolean; + languages: string[]; + last_air_date: string; + last_episode_to_air: { + id: number; + name: string; + overview: string; + vote_average: number; + vote_count: number; + air_date: string; + episode_number: number; + production_code: string; + runtime: number | null; + season_number: number; + show_id: number; + still_path: string | null; + } | null; + name: string; + next_episode_to_air: { + id: number; + name: string; + overview: string; + vote_average: number; + vote_count: number; + air_date: string; + episode_number: number; + production_code: string; + runtime: number | null; + season_number: number; + show_id: number; + still_path: string | null; + } | null; + networks: { + id: number; + logo_path: string; + name: string; + origin_country: string; + }[]; + number_of_episodes: number; + number_of_seasons: number; + origin_country: string[]; + original_language: string; + original_name: string; + overview: string; + popularity: number; + poster_path: string | null; + production_companies: { + id: number; + logo_path: string | null; + name: string; + origin_country: string; + }[]; + production_countries: { + iso_3166_1: string; + name: string; + }[]; + seasons: { + air_date: string; + episode_count: number; + id: number; + name: string; + overview: string; + poster_path: string | null; + season_number: number; + }[]; + spoken_languages: { + english_name: string; + iso_639_1: string; + name: string; + }[]; + status: string; + tagline: string; + type: string; + vote_average: number; + vote_count: number; + external_ids: { + imdb_id: string | null; }; } + +export interface TMDBMovieData { + adult: boolean; + backdrop_path: string | null; + belongs_to_collection: { + id: number; + name: string; + poster_path: string | null; + backdrop_path: string | null; + } | null; + budget: number; + genres: { + id: number; + name: string; + }[]; + homepage: string | null; + id: number; + imdb_id: string | null; + original_language: string; + original_title: string; + overview: string | null; + popularity: number; + poster_path: string | null; + production_companies: { + id: number; + logo_path: string | null; + name: string; + origin_country: string; + }[]; + production_countries: { + iso_3166_1: string; + name: string; + }[]; + release_date: string; + revenue: number; + runtime: number | null; + spoken_languages: { + english_name: string; + iso_639_1: string; + name: string; + }[]; + status: string; + tagline: string | null; + title: string; + video: boolean; + vote_average: number; + vote_count: number; + external_ids: { + imdb_id: string | null; + }; +} + +export interface TMDBEpisodeResult { + season: number; + number: number; + title: string; + ids: { + trakt: number; + tvdb: number; + imdb: string; + tmdb: number; + }; +} + +export interface TMDBEpisode { + air_date: string; + episode_number: number; + id: number; + name: string; + overview: string; + production_code: string; + runtime: number; + season_number: number; + show_id: number; + still_path: string | null; + vote_average: number; + vote_count: number; + crew: any[]; + guest_stars: any[]; +} + +export interface TMDBSeason { + _id: string; + air_date: string; + episodes: TMDBEpisode[]; + name: string; + overview: string; + id: number; + poster_path: string | null; + season_number: number; +} + +export interface ExternalIdMovieSearchResult { + movie_results: { + adult: boolean; + backdrop_path: string; + id: number; + title: string; + original_language: string; + original_title: string; + overview: string; + poster_path: string; + media_type: string; + genre_ids: number[]; + popularity: number; + release_date: string; + video: boolean; + vote_average: number; + vote_count: number; + }[]; + person_results: any[]; + tv_results: any[]; + tv_episode_results: any[]; + tv_season_results: any[]; +} + +export interface TMDBMovieSearchResult { + adult: boolean; + backdrop_path: string; + id: number; + title: string; + original_language: string; + original_title: string; + overview: string; + poster_path: string; + media_type: TMDBContentTypes.MOVIE; + genre_ids: number[]; + popularity: number; + release_date: string; + video: boolean; + vote_average: number; + vote_count: number; +} + +export interface TMDBShowSearchResult { + adult: boolean; + backdrop_path: string; + id: number; + name: string; + original_language: string; + original_name: string; + overview: string; + poster_path: string; + media_type: TMDBContentTypes.TV; + genre_ids: number[]; + popularity: number; + first_air_date: string; + vote_average: number; + vote_count: number; + origin_country: string[]; +} + +export interface TMDBSearchResult { + page: number; + results: (TMDBMovieSearchResult | TMDBShowSearchResult)[]; + total_pages: number; + total_results: number; +} diff --git a/src/components/media/ModalEpisodeSelector.tsx b/src/components/media/ModalEpisodeSelector.tsx index d05d1c22..e19b6556 100644 --- a/src/components/media/ModalEpisodeSelector.tsx +++ b/src/components/media/ModalEpisodeSelector.tsx @@ -1,15 +1,21 @@ import React, { useCallback, useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { get } from "@/backend/metadata/tmdb"; import { conf } from "@/setup/config"; interface ModalEpisodeSelectorProps { tmdbId: string; + mediaTitle: string; } -export function EpisodeSelector({ tmdbId }: ModalEpisodeSelectorProps) { +export function EpisodeSelector({ + tmdbId, + mediaTitle, +}: ModalEpisodeSelectorProps) { const [seasonsData, setSeasonsData] = useState([]); const [selectedSeason, setSelectedSeason] = useState(null); + const navigate = useNavigate(); const handleSeasonSelect = useCallback( async (season: any) => { @@ -37,7 +43,12 @@ export function EpisodeSelector({ tmdbId }: ModalEpisodeSelectorProps) { language: "en-US", }); setSeasonsData(showDetails.seasons); - handleSeasonSelect(showDetails.seasons[0]); // Default to first season + if (showDetails.seasons[0] === 0) { + // Default to first season + handleSeasonSelect(showDetails.seasons[0]); + } else { + handleSeasonSelect(showDetails.seasons[1]); + } } catch (err) { console.error(err); } @@ -47,18 +58,25 @@ export function EpisodeSelector({ tmdbId }: ModalEpisodeSelectorProps) { return (
-
+
{seasonsData.map((season) => (
handleSeasonSelect(season)} - className="cursor-pointer hover:bg-search-background p-1 text-center rounded hover:scale-95 transition-transform duration-300" + className={`cursor-pointer p-1 text-center rounded transition-transform duration-200 ${ + selectedSeason && + season.season_number === selectedSeason.season_number + ? "bg-search-background" + : "hover:bg-search-background hover:scale-95" + }`} > - S{season.season_number} + {season.season_number !== 0 + ? `S${season.season_number}` + : `Specials`}
))}
-
+
{selectedSeason ? ( selectedSeason.episodes.map( @@ -66,18 +84,25 @@ export function EpisodeSelector({ tmdbId }: ModalEpisodeSelectorProps) { episode_number: number; name: string; still_path: string; + show_id: number; + id: number; }) => (
+ navigate( + `/media/tmdb-tv-${tmdbId}-${mediaTitle}/${episode.show_id}/${episode.id}`, + ) + } + className="bg-mediaCard-hoverBackground rounded p-2 hover:scale-95 transition-transform transition-border-color duration-[0.28s] ease-in-out transform-origin-center" > {episode.name} -

{episode.name}

-
+

+ {episode.name} +

), ) diff --git a/src/components/media/PopupModal.tsx b/src/components/media/PopupModal.tsx index 0b47241f..3274c31d 100644 --- a/src/components/media/PopupModal.tsx +++ b/src/components/media/PopupModal.tsx @@ -152,7 +152,7 @@ export function PopupModal({ return (
)) - : Array.from({ length: 3 }).map((_, i) => ( - // eslint-disable-next-line react/no-array-index-key -
+ : Array.from({ length: 3 }).map((_) => ( +
))} @@ -269,7 +268,11 @@ export function PopupModal({
{data?.overview}
-
{isTVShow ? : null}
+
+ {isTVShow ? ( + + ) : null} +