From 3ee9ee43a54cf5f0a21eac66b333fed749663644 Mon Sep 17 00:00:00 2001 From: castdrian Date: Tue, 13 Jun 2023 21:23:47 +0200 Subject: [PATCH] refactor everything to use tmdb exclusively --- src/backend/metadata/getmeta.ts | 61 +++--- src/backend/metadata/search.ts | 20 +- src/backend/metadata/tmdb.ts | 159 ++++++++++++++- src/backend/metadata/trakttv.ts | 187 ------------------ src/backend/metadata/types.ts | 140 +++++++++---- src/components/media/MediaCard.tsx | 4 +- .../popouts/EpisodeSelectionPopout.tsx | 4 +- src/views/media/MediaView.tsx | 4 +- 8 files changed, 315 insertions(+), 264 deletions(-) delete mode 100644 src/backend/metadata/trakttv.ts diff --git a/src/backend/metadata/getmeta.ts b/src/backend/metadata/getmeta.ts index 82b3c20b..777fae42 100644 --- a/src/backend/metadata/getmeta.ts +++ b/src/backend/metadata/getmeta.ts @@ -1,22 +1,22 @@ import { FetchError } from "ofetch"; import { formatJWMeta, mediaTypeToJW } from "./justwatch"; -import { Tmdb } from "./tmdb"; import { - TTVMediaToMediaType, - Trakt, - formatTTVMeta, - mediaTypeToTTV, -} from "./trakttv"; + TMDBMediaToMediaType, + Tmdb, + formatTMDBMeta, + mediaTypeToTMDB, +} from "./tmdb"; import { JWMediaResult, JWSeasonMetaResult, JW_API_BASE, MWMediaMeta, MWMediaType, + TMDBMediaResult, TMDBMovieData, + TMDBSeasonMetaResult, TMDBShowData, - TTVSeasonMetaResult, } from "./types"; import { makeUrl, proxiedFetch } from "../helpers/fetch"; @@ -48,9 +48,7 @@ export async function getMetaFromId( id: string, seasonId?: string ): Promise { - const result = await Trakt.searchById(id, mediaTypeToJW(type)); - if (!result) return null; - const details = await Tmdb.getMediaDetails(id, type); + const details = await Tmdb.getMediaDetails(id, mediaTypeToTMDB(type)); if (!details) return null; @@ -59,15 +57,15 @@ export async function getMetaFromId( imdbId = (details as TMDBMovieData).imdb_id ?? undefined; } - let seasonData: TTVSeasonMetaResult | undefined; + let seasonData: TMDBSeasonMetaResult | undefined; if (type === MWMediaType.SERIES) { const seasons = (details as TMDBShowData).seasons; const season = seasons?.find((v) => v.id.toString() === seasonId) ?? seasons?.[0]; - const episodes = await Trakt.getEpisodes( - result.ttv_entity_id, + const episodes = await Tmdb.getEpisodes( + details.id.toString(), season?.season_number ?? 1 ); @@ -81,10 +79,27 @@ export async function getMetaFromId( } } - const meta = formatTTVMeta(result, seasonData); - if (!meta) return null; + const tmdbmeta: TMDBMediaResult = { + id: details.id, + title: + type === MWMediaType.MOVIE + ? (details as TMDBMovieData).title + : (details as TMDBShowData).name, + object_type: mediaTypeToTMDB(type), + seasons: (details as TMDBShowData).seasons.map((v) => ({ + id: v.id, + season_number: v.season_number, + title: v.name, + })), + poster: (details as TMDBMovieData).poster_path ?? undefined, + original_release_year: + type === MWMediaType.MOVIE + ? Number((details as TMDBMovieData).release_date?.split("-")[0]) + : Number((details as TMDBShowData).first_air_date?.split("-")[0]), + }; - console.log(meta); + const meta = formatTMDBMeta(tmdbmeta, seasonData); + if (!meta) return null; return { meta, @@ -143,18 +158,18 @@ export async function getLegacyMetaFromId( }; } -export function TTVMediaToId(media: MWMediaMeta): string { - return ["TTV", mediaTypeToTTV(media.type), media.id].join("-"); +export function TMDBMediaToId(media: MWMediaMeta): string { + return ["tmdb", mediaTypeToTMDB(media.type), media.id].join("-"); } -export function decodeTTVId( +export function decodeTMDBId( paramId: string ): { id: string; type: MWMediaType } | null { const [prefix, type, id] = paramId.split("-", 3); - if (prefix !== "TTV") return null; + if (prefix !== "tmdb") return null; let mediaType; try { - mediaType = TTVMediaToMediaType(type); + mediaType = TMDBMediaToMediaType(type); } catch { return null; } @@ -170,11 +185,11 @@ export async function convertLegacyUrl( if (url.startsWith("/media/JW")) { const urlParts = url.split("/").slice(2); const [, type, id] = urlParts[0].split("-", 3); - const meta = await getLegacyMetaFromId(TTVMediaToMediaType(type), id); + const meta = await getLegacyMetaFromId(TMDBMediaToMediaType(type), id); if (!meta) return undefined; const tmdbId = meta.tmdbId; if (!tmdbId) return undefined; - return `/media/TTV-${type}-${tmdbId}`; + return `/media/tmdb-${type}-${tmdbId}`; } return undefined; } diff --git a/src/backend/metadata/search.ts b/src/backend/metadata/search.ts index 8eb246b7..31b2c682 100644 --- a/src/backend/metadata/search.ts +++ b/src/backend/metadata/search.ts @@ -1,6 +1,11 @@ import { SimpleCache } from "@/utils/cache"; -import { Trakt, mediaTypeToTTV } from "./trakttv"; +import { + Tmdb, + formatTMDBMeta, + formatTMDBSearchResult, + mediaTypeToTMDB, +} from "./tmdb"; import { MWMediaMeta, MWQuery } from "./types"; const cache = new SimpleCache(); @@ -13,10 +18,17 @@ export async function searchForMedia(query: MWQuery): Promise { if (cache.has(query)) return cache.get(query) as MWMediaMeta[]; const { searchQuery, type } = query; - const contentType = mediaTypeToTTV(type); + const data = await Tmdb.searchMedia(searchQuery, mediaTypeToTMDB(type)); + const results = await Promise.all( + data.results.map(async (v) => { + const formattedResult = await formatTMDBSearchResult( + v, + mediaTypeToTMDB(type) + ); + return formatTMDBMeta(formattedResult); + }) + ); - const results = await Trakt.search(searchQuery, contentType); - console.log(results[0]); cache.set(query, results, 3600); return results; } diff --git a/src/backend/metadata/tmdb.ts b/src/backend/metadata/tmdb.ts index 3aa1821f..f01709bb 100644 --- a/src/backend/metadata/tmdb.ts +++ b/src/backend/metadata/tmdb.ts @@ -1,13 +1,100 @@ import { conf } from "@/setup/config"; import { + MWMediaMeta, MWMediaType, + MWSeasonMeta, + TMDBContentTypes, + TMDBEpisodeShort, + TMDBMediaResult, TMDBMediaStatic, TMDBMovieData, + TMDBMovieResponse, + TMDBMovieResult, + TMDBSearchResultStatic, + TMDBSeason, + TMDBSeasonMetaResult, TMDBShowData, + TMDBShowResponse, + TMDBShowResult, } from "./types"; import { mwFetch } from "../helpers/fetch"; +export function mediaTypeToTMDB(type: MWMediaType): TMDBContentTypes { + if (type === MWMediaType.MOVIE) return "movie"; + if (type === MWMediaType.SERIES) return "show"; + throw new Error("unsupported type"); +} + +export function TMDBMediaToMediaType(type: string): MWMediaType { + if (type === "movie") return MWMediaType.MOVIE; + if (type === "show") return MWMediaType.SERIES; + 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_year?.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, + })), + } as any) + : (undefined as any), + }; +} + +export function TMDBMediaToId(media: MWMediaMeta): string { + return ["tmdb", mediaTypeToTMDB(media.type), media.id].join("-"); +} + +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); + } catch { + return null; + } + return { + type: mediaType, + id, + }; +} + export abstract class Tmdb { private static baseURL = "https://api.themoviedb.org/3"; @@ -24,9 +111,33 @@ export abstract class Tmdb { return res; } + public static searchMedia: TMDBSearchResultStatic["searchMedia"] = async ( + query: string, + type: TMDBContentTypes + ) => { + let data; + + switch (type) { + case "movie": + data = await Tmdb.get( + `search/movie?query=${query}&include_adult=true&language=en-US&page=1` + ); + break; + case "show": + data = await Tmdb.get( + `search/tv?query=${query}&include_adult=true&language=en-US&page=1` + ); + break; + default: + throw new Error("Invalid media type"); + } + + return data; + }; + public static getMediaDetails: TMDBMediaStatic["getMediaDetails"] = async ( id: string, - type: MWMediaType + type: TMDBContentTypes ) => { let data; @@ -34,7 +145,7 @@ export abstract class Tmdb { case "movie": data = await Tmdb.get(`/movie/${id}`); break; - case "series": + case "show": data = await Tmdb.get(`/tv/${id}`); break; default: @@ -47,4 +158,48 @@ export abstract class Tmdb { public static getMediaPoster(posterPath: string | null): string | undefined { if (posterPath) return `https://image.tmdb.org/t/p/w185/${posterPath}`; } + + public static async getEpisodes( + id: string, + season: number + ): Promise { + const data = await Tmdb.get(`/tv/${id}/season/${season}`); + return data.episodes.map((e) => ({ + id: e.id, + episode_number: e.episode_number, + title: e.name, + })); + } +} + +export async function formatTMDBSearchResult( + result: TMDBShowResult | TMDBMovieResult, + mediatype: TMDBContentTypes +): Promise { + const type = TMDBMediaToMediaType(mediatype); + const details = await Tmdb.getMediaDetails(result.id.toString(), mediatype); + + const seasons = + type === MWMediaType.SERIES + ? (details as TMDBShowData).seasons?.map((v) => ({ + id: v.id, + title: v.name, + season_number: v.season_number, + })) + : undefined; + + return { + title: + type === MWMediaType.SERIES + ? (result as TMDBShowResult).name + : (result as TMDBMovieResult).title, + poster: Tmdb.getMediaPoster(details.poster_path), + id: result.id, + original_release_year: + type === MWMediaType.SERIES + ? Number((result as TMDBShowResult).first_air_date?.split("-")[0]) + : Number((result as TMDBMovieResult).release_date?.split("-")[0]), + object_type: mediaTypeToTMDB(type), + seasons, + }; } diff --git a/src/backend/metadata/trakttv.ts b/src/backend/metadata/trakttv.ts deleted file mode 100644 index ae50aefe..00000000 --- a/src/backend/metadata/trakttv.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { conf } from "@/setup/config"; - -import { Tmdb } from "./tmdb"; -import { - MWMediaMeta, - MWMediaType, - MWSeasonMeta, - TMDBShowData, - TTVContentTypes, - TTVEpisodeResult, - TTVEpisodeShort, - TTVMediaResult, - TTVSearchResult, - TTVSeasonMetaResult, -} from "./types"; -import { mwFetch } from "../helpers/fetch"; - -export function mediaTypeToTTV(type: MWMediaType): TTVContentTypes { - if (type === MWMediaType.MOVIE) return "movie"; - if (type === MWMediaType.SERIES) return "show"; - throw new Error("unsupported type"); -} - -export function TTVMediaToMediaType(type: string): MWMediaType { - if (type === "movie") return MWMediaType.MOVIE; - if (type === "show") return MWMediaType.SERIES; - throw new Error("unsupported type"); -} - -export function formatTTVMeta( - media: TTVMediaResult, - season?: TTVSeasonMetaResult -): MWMediaMeta { - const type = TTVMediaToMediaType(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_year?.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, - })), - } as any) - : (undefined as any), - }; -} - -export function TTVMediaToId(media: MWMediaMeta): string { - return ["MW", mediaTypeToTTV(media.type), media.id].join("-"); -} - -export function decodeTTVId( - paramId: string -): { id: string; type: MWMediaType } | null { - const [prefix, type, id] = paramId.split("-", 3); - if (prefix !== "MW") return null; - let mediaType; - try { - mediaType = TTVMediaToMediaType(type); - } catch { - return null; - } - return { - type: mediaType, - id, - }; -} - -export async function formatTTVSearchResult( - result: TTVSearchResult -): Promise { - const type = TTVMediaToMediaType(result.type); - const media = result[result.type]; - - if (!media) throw new Error("invalid result"); - - const details = await Tmdb.getMediaDetails( - media.ids.tmdb.toString(), - TTVMediaToMediaType(result.type) - ); - - const seasons = - type === MWMediaType.SERIES - ? (details as TMDBShowData).seasons?.map((v) => ({ - id: v.id, - title: v.name, - season_number: v.season_number, - })) - : undefined; - - return { - title: media.title, - poster: Tmdb.getMediaPoster(details.poster_path), - id: media.ids.tmdb, - original_release_year: media.year, - ttv_entity_id: media.ids.slug, - object_type: mediaTypeToTTV(type), - seasons, - }; -} - -export abstract class Trakt { - private static baseURL = "https://api.trakt.tv"; - - private static headers = { - "Content-Type": "application/json", - "trakt-api-version": "2", - "trakt-api-key": conf().TRAKT_CLIENT_ID, - }; - - private static async get(url: string): Promise { - const res = await mwFetch(url, { - headers: Trakt.headers, - baseURL: Trakt.baseURL, - }); - return res; - } - - public static async search( - query: string, - type: "movie" | "show" - ): Promise { - const data = await Trakt.get( - `/search/${type}?query=${encodeURIComponent(query)}` - ); - - const formatted = await Promise.all( - // eslint-disable-next-line no-return-await - data.map(async (v) => await formatTTVSearchResult(v)) - ); - return formatted.map((v) => formatTTVMeta(v)); - } - - public static async searchById( - tmdbId: string, - type: "movie" | "show" - ): Promise { - const data = await Trakt.get( - `/search/tmdb/${tmdbId}?type=${type}` - ); - - const formatted = await Promise.all( - // eslint-disable-next-line no-return-await - data.map(async (v) => await formatTTVSearchResult(v)) - ); - return formatted[0]; - } - - public static async getEpisodes( - slug: string, - season: number - ): Promise { - const data = await Trakt.get( - `/shows/${slug}/seasons/${season}` - ); - - return data.map((e) => ({ - id: e.ids.tmdb, - episode_number: e.number, - title: e.title, - })); - } -} diff --git a/src/backend/metadata/types.ts b/src/backend/metadata/types.ts index 07671e39..e23d9a5b 100644 --- a/src/backend/metadata/types.ts +++ b/src/backend/metadata/types.ts @@ -46,63 +46,36 @@ export interface MWQuery { type: MWMediaType; } -export type TTVContentTypes = "movie" | "show"; +export type TMDBContentTypes = "movie" | "show"; -export type TTVSeasonShort = { +export type TMDBSeasonShort = { title: string; id: number; season_number: number; }; -export type TTVEpisodeShort = { +export type TMDBEpisodeShort = { title: string; id: number; episode_number: number; }; -export type TTVMediaResult = { +export type TMDBMediaResult = { title: string; poster?: string; id: number; original_release_year?: number; - ttv_entity_id: string; - object_type: TTVContentTypes; - seasons?: TTVSeasonShort[]; + object_type: TMDBContentTypes; + seasons?: TMDBSeasonShort[]; }; -export type TTVSeasonMetaResult = { +export type TMDBSeasonMetaResult = { title: string; id: string; season_number: number; - episodes: TTVEpisodeShort[]; + episodes: TMDBEpisodeShort[]; }; -export interface TTVSearchResult { - type: "movie" | "show"; - score: number; - movie?: { - title: string; - year: number; - ids: { - trakt: number; - slug: string; - imdb: string; - tmdb: number; - }; - }; - show?: { - title: string; - year: number; - ids: { - trakt: number; - slug: string; - tvdb: number; - imdb: string; - tmdb: number; - }; - }; -} - export interface DetailedMeta { meta: MWMediaMeta; imdbId?: string; @@ -255,12 +228,9 @@ export interface TMDBMovieData { export type TMDBMediaDetailsPromise = Promise; export interface TMDBMediaStatic { - getMediaDetails( - id: string, - type: MWMediaType.SERIES - ): TMDBMediaDetailsPromise; - getMediaDetails(id: string, type: MWMediaType.MOVIE): TMDBMediaDetailsPromise; - getMediaDetails(id: string, type: MWMediaType): TMDBMediaDetailsPromise; + getMediaDetails(id: string, type: "show"): TMDBMediaDetailsPromise; + getMediaDetails(id: string, type: "movie"): TMDBMediaDetailsPromise; + getMediaDetails(id: string, type: TMDBContentTypes): TMDBMediaDetailsPromise; } export type JWContentTypes = "movie" | "show"; @@ -312,7 +282,7 @@ export type JWSeasonMetaResult = { episodes: JWEpisodeShort[]; }; -export interface TTVEpisodeResult { +export interface TMDBEpisodeResult { season: number; number: number; title: string; @@ -323,3 +293,89 @@ export interface TTVEpisodeResult { tmdb: number; }; } + +export interface TMDBShowResult { + adult: boolean; + backdrop_path: string | null; + genre_ids: number[]; + id: number; + origin_country: string[]; + original_language: string; + original_name: string; + overview: string; + popularity: number; + poster_path: string | null; + first_air_date: string; + name: string; + vote_average: number; + vote_count: number; +} + +export interface TMDBShowResponse { + page: number; + results: TMDBShowResult[]; + total_pages: number; + total_results: number; +} + +export interface TMDBMovieResult { + adult: boolean; + backdrop_path: string | null; + genre_ids: number[]; + id: number; + original_language: string; + original_title: string; + overview: string; + popularity: number; + poster_path: string | null; + release_date: string; + title: string; + video: boolean; + vote_average: number; + vote_count: number; +} + +export interface TMDBMovieResponse { + page: number; + results: TMDBMovieResult[]; + total_pages: number; + total_results: number; +} + +export type TMDBSearchResultsPromise = Promise< + TMDBShowResponse | TMDBMovieResponse +>; + +export interface TMDBSearchResultStatic { + searchMedia(query: string, type: TMDBContentTypes): TMDBSearchResultsPromise; + searchMedia(query: string, type: "movie"): TMDBSearchResultsPromise; + searchMedia(query: string, type: "show"): TMDBSearchResultsPromise; +} + +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; +} diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index b87654a9..695027a2 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; -import { TTVMediaToId } from "@/backend/metadata/getmeta"; +import { TMDBMediaToId } from "@/backend/metadata/getmeta"; import { MWMediaMeta } from "@/backend/metadata/types"; import { DotList } from "@/components/text/DotList"; @@ -132,7 +132,7 @@ export function MediaCard(props: MediaCardProps) { const canLink = props.linkable && !props.closable; let link = canLink - ? `/media/${encodeURIComponent(TTVMediaToId(props.media))}` + ? `/media/${encodeURIComponent(TMDBMediaToId(props.media))}` : "#"; if (canLink && props.series) link += `/${encodeURIComponent(props.series.seasonId)}/${encodeURIComponent( diff --git a/src/video/components/popouts/EpisodeSelectionPopout.tsx b/src/video/components/popouts/EpisodeSelectionPopout.tsx index 6cf5d2d6..bc050bf2 100644 --- a/src/video/components/popouts/EpisodeSelectionPopout.tsx +++ b/src/video/components/popouts/EpisodeSelectionPopout.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useParams } from "react-router-dom"; -import { decodeTTVId, getMetaFromId } from "@/backend/metadata/getmeta"; +import { decodeTMDBId, getMetaFromId } from "@/backend/metadata/getmeta"; import { MWMediaType, MWSeasonWithEpisodeMeta } from "@/backend/metadata/types"; import { IconPatch } from "@/components/buttons/IconPatch"; import { Icon, Icons } from "@/components/Icon"; @@ -44,7 +44,7 @@ export function EpisodeSelectionPopout() { seasonId: sId, season: undefined, }); - reqSeasonMeta(decodeTTVId(params.media)?.id as string, sId).then((v) => { + reqSeasonMeta(decodeTMDBId(params.media)?.id as string, sId).then((v) => { if (v?.meta.type !== MWMediaType.SERIES) return; setCurrentVisibleSeason({ seasonId: sId, diff --git a/src/views/media/MediaView.tsx b/src/views/media/MediaView.tsx index c0e81455..7ae1c01b 100644 --- a/src/views/media/MediaView.tsx +++ b/src/views/media/MediaView.tsx @@ -6,7 +6,7 @@ import { useHistory, useParams } from "react-router-dom"; import { MWStream } from "@/backend/helpers/streams"; import { DetailedMeta, - decodeTTVId, + decodeTMDBId, getMetaFromId, } from "@/backend/metadata/getmeta"; import { MWMediaType, MWSeasonWithEpisodeMeta } from "@/backend/metadata/types"; @@ -184,7 +184,7 @@ export function MediaView() { const [selected, setSelected] = useState(null); const [exec, loading, error] = useLoading( async (mediaParams: string, seasonId?: string) => { - const data = decodeTTVId(mediaParams); + const data = decodeTMDBId(mediaParams); if (!data) return null; return getMetaFromId(data.type, data.id, seasonId); }