diff --git a/src/backend/metadata/search_new.ts b/src/backend/metadata/search_new.ts new file mode 100644 index 00000000..4506514a --- /dev/null +++ b/src/backend/metadata/search_new.ts @@ -0,0 +1,21 @@ +import { SimpleCache } from "@/utils/cache"; + +import { Trakt, mediaTypeToTTV } from "./trakttv"; +import { MWMediaMeta, MWQuery } from "./types"; + +const cache = new SimpleCache(); +cache.setCompare((a, b) => { + return a.type === b.type && a.searchQuery.trim() === b.searchQuery.trim(); +}); +cache.initialize(); + +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 results = await Trakt.search(searchQuery, contentType); + cache.set(query, results, 3600); + return results; +} diff --git a/src/backend/metadata/tmdb.ts b/src/backend/metadata/tmdb.ts new file mode 100644 index 00000000..0700945b --- /dev/null +++ b/src/backend/metadata/tmdb.ts @@ -0,0 +1,78 @@ +import { conf } from "@/setup/config"; + +import { + DetailedMeta, + MWMediaType, + TMDBMediaStatic, + TMDBMovieData, + TMDBShowData, +} from "./types"; +import { mwFetch } from "../helpers/fetch"; + +export abstract class Tmdb { + private static baseURL = "https://api.themoviedb.org/3"; + + private static headers = { + accept: "application/json", + Authorization: `Bearer ${conf().TMDB_API_KEY}`, + }; + + private static async get(url: string): Promise { + const res = await mwFetch(url, { + headers: Tmdb.headers, + baseURL: Tmdb.baseURL, + }); + return res; + } + + public static getMediaDetails: TMDBMediaStatic["getMediaDetails"] = async ( + id: string, + type: MWMediaType + ) => { + let data; + + switch (type) { + case "movie": + data = await Tmdb.get(`/movie/${id}`); + break; + case "series": + data = await Tmdb.get(`/tv/${id}`); + break; + default: + throw new Error("Invalid media type"); + } + + return data; + }; + + public static getMediaPoster(posterPath: string | null): string | undefined { + if (posterPath) return `https://image.tmdb.org/t/p/w185/${posterPath}`; + } + + /* public static async getMetaFromId( + type: MWMediaType, + id: string, + seasonId?: string + ): Promise { + console.log("getMetaFromId", type, id, seasonId); + + const details = await Tmdb.getMediaDetails(id, type); + + if (!details) return null; + + let imdbId; + if (type === MWMediaType.MOVIE) { + imdbId = (details as TMDBMovieData).imdb_id ?? undefined; + } + + if (!meta.length) return null; + + console.log(meta); + + return { + meta, + imdbId, + tmdbId: id, + }; + } */ +} diff --git a/src/backend/metadata/trakttv.ts b/src/backend/metadata/trakttv.ts new file mode 100644 index 00000000..5fb67a17 --- /dev/null +++ b/src/backend/metadata/trakttv.ts @@ -0,0 +1,166 @@ +import { conf } from "@/setup/config"; + +import { Tmdb } from "./tmdb"; +import { + DetailedMeta, + MWMediaMeta, + MWMediaType, + MWSeasonMeta, + TMDBShowData, + TTVContentTypes, + 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 ["TTV", 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 !== "TTV") 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) + ); + console.log(details); + + 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.trakt, + 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 getMetaFromId( + type: MWMediaType, + id: string, + seasonId?: string + ): Promise { + console.log("getMetaFromId", type, id, seasonId); + return null; + } +} diff --git a/src/backend/metadata/types_new.ts b/src/backend/metadata/types_new.ts new file mode 100644 index 00000000..f954ff6a --- /dev/null +++ b/src/backend/metadata/types_new.ts @@ -0,0 +1,264 @@ +export enum MWMediaType { + MOVIE = "movie", + SERIES = "series", + ANIME = "anime", +} + +export type MWSeasonMeta = { + id: string; + number: number; + title: string; +}; + +export type MWSeasonWithEpisodeMeta = { + id: string; + number: number; + title: string; + episodes: { + id: string; + number: number; + title: string; + }[]; +}; + +type MWMediaMetaBase = { + title: string; + id: string; + year?: string; + poster?: string; +}; + +type MWMediaMetaSpecific = + | { + type: MWMediaType.MOVIE | MWMediaType.ANIME; + seasons: undefined; + } + | { + type: MWMediaType.SERIES; + seasons: MWSeasonMeta[]; + seasonData: MWSeasonWithEpisodeMeta; + }; + +export type MWMediaMeta = MWMediaMetaBase & MWMediaMetaSpecific; + +export interface MWQuery { + searchQuery: string; + type: MWMediaType; +} + +export type TTVContentTypes = "movie" | "show"; + +export type TTVSeasonShort = { + title: string; + id: number; + season_number: number; +}; + +export type TTVEpisodeShort = { + title: string; + id: number; + episode_number: number; +}; + +export type TTVMediaResult = { + title: string; + poster?: string; + id: number; + original_release_year?: number; + ttv_entity_id: string; + object_type: TTVContentTypes; + seasons?: TTVSeasonShort[]; +}; + +export type TTVSeasonMetaResult = { + title: string; + id: string; + season_number: number; + episodes: TTVEpisodeShort[]; +}; + +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; + tmdbId?: string; +} + +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; +} + +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; +} + +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; +}