diff --git a/package.json b/package.json index 3228d02f..d315bd23 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "react-stickynode": "^4.1.0", "react-transition-group": "^4.4.5", "react-use": "^17.4.0", + "slugify": "^1.6.6", "subsrt-ts": "^2.1.1", "unpacker": "^1.0.1" }, diff --git a/src/backend/metadata/getmeta.ts b/src/backend/metadata/getmeta.ts index 3893db53..6081e5ad 100644 --- a/src/backend/metadata/getmeta.ts +++ b/src/backend/metadata/getmeta.ts @@ -2,6 +2,7 @@ import { FetchError } from "ofetch"; import { formatJWMeta, mediaTypeToJW } from "./justwatch"; import { + TMDBIdToUrlId, TMDBMediaToMediaType, formatTMDBMeta, getEpisodes, @@ -12,7 +13,7 @@ import { mediaTypeToTMDB, } from "./tmdb"; import { - JWMediaResult, + JWDetailedMeta, JWSeasonMetaResult, JW_API_BASE, } from "./types/justwatch"; @@ -25,23 +26,6 @@ import { } from "./types/tmdb"; import { makeUrl, proxiedFetch } from "../helpers/fetch"; -type JWExternalIdType = - | "eidr" - | "imdb_latest" - | "imdb" - | "tmdb_latest" - | "tmdb" - | "tms"; - -interface JWExternalId { - provider: JWExternalIdType; - external_id: string; -} - -interface JWDetailedMeta extends JWMediaResult { - external_ids: JWExternalId[]; -} - export interface DetailedMeta { meta: MWMediaMeta; imdbId?: string; @@ -180,27 +164,6 @@ export async function getLegacyMetaFromId( }; } -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 function isLegacyUrl(url: string): boolean { if (url.startsWith("/media/JW")) return true; return false; @@ -224,10 +187,12 @@ export async function convertLegacyUrl( // movies always have an imdb id on tmdb if (imdbId && mediaType === MWMediaType.MOVIE) { const movieId = await getMovieFromExternalId(imdbId); - if (movieId) return `/media/tmdb-movie-${movieId}`; - } + if (movieId) { + return `/media/${TMDBIdToUrlId(mediaType, movieId, meta.meta.title)}`; + } - if (tmdbId) { - return `/media/tmdb-${type}-${tmdbId}`; + if (tmdbId) { + return `/media/${TMDBIdToUrlId(mediaType, tmdbId, meta.meta.title)}`; + } } } diff --git a/src/backend/metadata/tmdb.ts b/src/backend/metadata/tmdb.ts index 9b38d995..64304900 100644 --- a/src/backend/metadata/tmdb.ts +++ b/src/backend/metadata/tmdb.ts @@ -1,3 +1,5 @@ +import slugify from "slugify"; + import { conf } from "@/setup/config"; import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types/mw"; @@ -11,12 +13,15 @@ import { TMDBMovieExternalIds, TMDBMovieResponse, TMDBMovieResult, + TMDBMovieSearchResult, + TMDBSearchResult, TMDBSeason, TMDBSeasonMetaResult, TMDBShowData, TMDBShowExternalIds, TMDBShowResponse, TMDBShowResult, + TMDBShowSearchResult, } from "./types/tmdb"; import { mwFetch } from "../helpers/fetch"; @@ -74,8 +79,21 @@ export function formatTMDBMeta( }; } +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 ["tmdb", mediaTypeToTMDB(media.type), media.id].join("-"); + return TMDBIdToUrlId(media.type, media.id, media.title); } export function decodeTMDBId( @@ -143,6 +161,38 @@ export async function searchMedia( return data; } +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 === "movie" || r.media_type === "tv" + ); + return results; +} + +export async function generateQuickSearchMediaUrl( + query: string +): Promise { + const data = await multiSearch(query); + if (data.length === 0) return undefined; + const result = data[0]; + const type = result.media_type === "movie" ? "movie" : "show"; + const title = result.media_type === "movie" ? result.title : result.name; + + return `/media/${TMDBIdToUrlId( + TMDBMediaToMediaType(type), + result.id.toString(), + title + )}`; +} + // Conditional type which for inferring the return type based on the content type type MediaDetailReturn = T extends "movie" ? TMDBMovieData diff --git a/src/backend/metadata/types/justwatch.ts b/src/backend/metadata/types/justwatch.ts index cb3ac092..b55e9e24 100644 --- a/src/backend/metadata/types/justwatch.ts +++ b/src/backend/metadata/types/justwatch.ts @@ -46,3 +46,20 @@ export type JWSeasonMetaResult = { season_number: number; episodes: JWEpisodeShort[]; }; + +export type JWExternalIdType = + | "eidr" + | "imdb_latest" + | "imdb" + | "tmdb_latest" + | "tmdb" + | "tms"; + +export interface JWExternalId { + provider: JWExternalIdType; + external_id: string; +} + +export interface JWDetailedMeta extends JWMediaResult { + external_ids: JWExternalId[]; +} diff --git a/src/backend/metadata/types/tmdb.ts b/src/backend/metadata/types/tmdb.ts index 843786f4..8f6bf14b 100644 --- a/src/backend/metadata/types/tmdb.ts +++ b/src/backend/metadata/types/tmdb.ts @@ -306,3 +306,46 @@ export interface ExternalIdMovieSearchResult { 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: "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: "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/MediaCard.tsx b/src/components/media/MediaCard.tsx index a153d8b4..e05fbebb 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 { TMDBMediaToId } from "@/backend/metadata/getmeta"; +import { TMDBMediaToId } from "@/backend/metadata/tmdb"; import { MWMediaMeta } from "@/backend/metadata/types/mw"; import { DotList } from "@/components/text/DotList"; diff --git a/src/setup/App.tsx b/src/setup/App.tsx index 7d1847ae..53f3c131 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -5,9 +5,11 @@ import { Switch, useHistory, useLocation, + useParams, } from "react-router-dom"; import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta"; +import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb"; import { MWMediaType } from "@/backend/metadata/types/mw"; import { BannerContextProvider } from "@/hooks/useBanner"; import { Layout } from "@/setup/Layout"; @@ -35,6 +37,23 @@ function LegacyUrlView({ children }: { children: ReactElement }) { return children; } +function QuickSearch() { + const { query } = useParams<{ query: string }>(); + const { replace } = useHistory(); + + useEffect(() => { + if (query) { + generateQuickSearchMediaUrl(query).then((url) => { + replace(url ?? "/"); + }); + } else { + replace("/"); + } + }, [query, replace]); + + return null; +} + function App() { return ( @@ -48,6 +67,9 @@ function App() { + + + {/* pages */} diff --git a/src/video/components/popouts/EpisodeSelectionPopout.tsx b/src/video/components/popouts/EpisodeSelectionPopout.tsx index 66c9ae49..a315a7d7 100644 --- a/src/video/components/popouts/EpisodeSelectionPopout.tsx +++ b/src/video/components/popouts/EpisodeSelectionPopout.tsx @@ -2,7 +2,8 @@ import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useParams } from "react-router-dom"; -import { decodeTMDBId, getMetaFromId } from "@/backend/metadata/getmeta"; +import { getMetaFromId } from "@/backend/metadata/getmeta"; +import { decodeTMDBId } from "@/backend/metadata/tmdb"; import { MWMediaType, MWSeasonWithEpisodeMeta, diff --git a/src/views/media/MediaView.tsx b/src/views/media/MediaView.tsx index 6e1659a6..ada4f9f8 100644 --- a/src/views/media/MediaView.tsx +++ b/src/views/media/MediaView.tsx @@ -4,11 +4,8 @@ import { useTranslation } from "react-i18next"; import { useHistory, useParams } from "react-router-dom"; import { MWStream } from "@/backend/helpers/streams"; -import { - DetailedMeta, - decodeTMDBId, - getMetaFromId, -} from "@/backend/metadata/getmeta"; +import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta"; +import { decodeTMDBId } from "@/backend/metadata/tmdb"; import { MWMediaType, MWSeasonWithEpisodeMeta, diff --git a/yarn.lock b/yarn.lock index a811afd3..5d687f2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4764,6 +4764,11 @@ slice-ansi@^5.0.0: ansi-styles "^6.0.0" is-fullwidth-code-point "^4.0.0" +slugify@^1.6.6: + version "1.6.6" + resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b" + integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw== + source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"