diff --git a/SELFHOSTING.md b/SELFHOSTING.md index 7e7fc0f3..c9598559 100644 --- a/SELFHOSTING.md +++ b/SELFHOSTING.md @@ -31,10 +31,11 @@ Your proxy is now hosted on Cloudflare. Note the url of your worker as you will 1. Download the file `movie-web.zip` from the latest release: [https://github.com/movie-web/movie-web/releases/latest](https://github.com/movie-web/movie-web/releases/latest). 2. Extract the zip file so you can edit the files. 3. Open `config.js` in Notepad, Visual Studio Code or similar. -4. Put your Cloudflare proxy URL in-between the double quotes of `VITE_CORS_PROXY_URL: "",`. Make sure to not have a slash at the end of your URL. +4. Put your Cloudflare proxy URL in-between the double quotes of `VITE_CORS_PROXY_URL: ""`. Make sure to not have a slash at the end of your URL. Example (THIS IS MINE, IT WONT WORK FOR YOU): `VITE_CORS_PROXY_URL: "https://test-proxy.test.workers.dev",` -5. Save the file. +5. Put your TMDB read access token inside the quotes of `VITE_TMDB_READ_API_KEY: ""`. You can generate it for free at [https://www.themoviedb.org/settings/api](https://www.themoviedb.org/settings/api). +6. Save the file Your client has now been prepared, you can now host it with any static website hosting (Common ones include [GitHub Pages](https://pages.github.com/), [Netlify](https://www.netlify.com/) and [Vercel](https://vercel.com/) but any will work!). It doesn't require PHP, it's just a standard static page. diff --git a/example.env b/example.env index 5416f0f1..d191d741 100644 --- a/example.env +++ b/example.env @@ -1,6 +1,3 @@ # make sure the cors proxy url does NOT have a slash at the end VITE_CORS_PROXY_URL=... - -# the keys below are optional - defaults are provided -VITE_TMDB_API_KEY=... -VITE_OMDB_API_KEY=... +VITE_TMDB_READ_API_KEY=... diff --git a/package.json b/package.json index 43436852..327ba30b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "movie-web", - "version": "3.0.15", + "version": "3.1.2", "private": true, "homepage": "https://movie-web.app", "dependencies": { diff --git a/public/config.js b/public/config.js index b69f60eb..c08704c2 100644 --- a/public/config.js +++ b/public/config.js @@ -1,6 +1,5 @@ window.__CONFIG__ = { // url must NOT end with a slash VITE_CORS_PROXY_URL: "", - VITE_TMDB_API_KEY: "b030404650f279792a8d3287232358e3", - VITE_OMDB_API_KEY: "aa0937c0", + VITE_TMDB_READ_API_KEY: "" }; diff --git a/src/__tests__/providers/providers.test.ts b/src/__tests__/providers/providers.test.ts index 35c77d5d..350d4255 100644 --- a/src/__tests__/providers/providers.test.ts +++ b/src/__tests__/providers/providers.test.ts @@ -4,7 +4,7 @@ import "@/backend"; import { testData } from "@/__tests__/providers/testdata"; import { getProviders } from "@/backend/helpers/register"; import { runProvider } from "@/backend/helpers/run"; -import { MWMediaType } from "@/backend/metadata/types"; +import { MWMediaType } from "@/backend/metadata/types/mw"; describe("providers", () => { const providers = getProviders(); diff --git a/src/__tests__/providers/testdata.ts b/src/__tests__/providers/testdata.ts index 37e63e06..6db686e3 100644 --- a/src/__tests__/providers/testdata.ts +++ b/src/__tests__/providers/testdata.ts @@ -1,5 +1,5 @@ import { DetailedMeta } from "@/backend/metadata/getmeta"; -import { MWMediaType } from "@/backend/metadata/types"; +import { MWMediaType } from "@/backend/metadata/types/mw"; export const testData: DetailedMeta[] = [ { diff --git a/src/backend/helpers/provider.ts b/src/backend/helpers/provider.ts index 6eed4560..58dea7d4 100644 --- a/src/backend/helpers/provider.ts +++ b/src/backend/helpers/provider.ts @@ -1,7 +1,7 @@ import { MWEmbed } from "./embed"; import { MWStream } from "./streams"; import { DetailedMeta } from "../metadata/getmeta"; -import { MWMediaType } from "../metadata/types"; +import { MWMediaType } from "../metadata/types/mw"; export type MWProviderScrapeResult = { stream?: MWStream; diff --git a/src/backend/helpers/scrape.ts b/src/backend/helpers/scrape.ts index 70e20348..5f1a100c 100644 --- a/src/backend/helpers/scrape.ts +++ b/src/backend/helpers/scrape.ts @@ -3,7 +3,7 @@ import { getEmbedScraperByType, getProviders } from "./register"; import { runEmbedScraper, runProvider } from "./run"; import { MWStream } from "./streams"; import { DetailedMeta } from "../metadata/getmeta"; -import { MWMediaType } from "../metadata/types"; +import { MWMediaType } from "../metadata/types/mw"; interface MWProgressData { type: "embed" | "provider"; diff --git a/src/backend/metadata/getmeta.ts b/src/backend/metadata/getmeta.ts index 6b3b9a30..3893db53 100644 --- a/src/backend/metadata/getmeta.ts +++ b/src/backend/metadata/getmeta.ts @@ -1,13 +1,28 @@ import { FetchError } from "ofetch"; +import { formatJWMeta, mediaTypeToJW } from "./justwatch"; +import { + TMDBMediaToMediaType, + formatTMDBMeta, + getEpisodes, + getExternalIds, + getMediaDetails, + getMediaPoster, + getMovieFromExternalId, + mediaTypeToTMDB, +} from "./tmdb"; import { JWMediaResult, JWSeasonMetaResult, JW_API_BASE, - formatJWMeta, - mediaTypeToJW, -} from "./justwatch"; -import { MWMediaMeta, MWMediaType } from "./types"; +} from "./types/justwatch"; +import { MWMediaMeta, MWMediaType } from "./types/mw"; +import { + TMDBMediaResult, + TMDBMovieData, + TMDBSeasonMetaResult, + TMDBShowData, +} from "./types/tmdb"; import { makeUrl, proxiedFetch } from "../helpers/fetch"; type JWExternalIdType = @@ -33,10 +48,92 @@ export interface DetailedMeta { tmdbId?: string; } +export function formatTMDBMetaResult( + details: TMDBShowData | TMDBMovieData, + type: MWMediaType +): TMDBMediaResult { + if (type === MWMediaType.MOVIE) { + const movie = details as TMDBMovieData; + return { + id: details.id, + title: movie.title, + object_type: mediaTypeToTMDB(type), + poster: getMediaPoster(movie.poster_path) ?? undefined, + original_release_year: new Date(movie.release_date).getFullYear(), + }; + } + if (type === MWMediaType.SERIES) { + const show = details as TMDBShowData; + return { + id: details.id, + title: show.name, + object_type: mediaTypeToTMDB(type), + seasons: show.seasons.map((v) => ({ + id: v.id, + season_number: v.season_number, + title: v.name, + })), + poster: getMediaPoster(show.poster_path) ?? undefined, + original_release_year: new Date(show.first_air_date).getFullYear(), + }; + } + + throw new Error("unsupported type"); +} + export async function getMetaFromId( type: MWMediaType, id: string, seasonId?: string +): Promise { + const details = await getMediaDetails(id, mediaTypeToTMDB(type)); + + if (!details) return null; + + const externalIds = await getExternalIds(id, mediaTypeToTMDB(type)); + const imdbId = externalIds.imdb_id ?? undefined; + + let seasonData: TMDBSeasonMetaResult | undefined; + + if (type === MWMediaType.SERIES) { + const seasons = (details as TMDBShowData).seasons; + + let selectedSeason = seasons.find((v) => v.id.toString() === seasonId); + if (!selectedSeason) { + selectedSeason = seasons.find((v) => v.season_number === 1); + } + + if (selectedSeason) { + const episodes = await getEpisodes( + details.id.toString(), + selectedSeason.season_number + ); + + seasonData = { + id: selectedSeason.id.toString(), + season_number: selectedSeason.season_number, + title: selectedSeason.name, + episodes, + }; + } + } + + const tmdbmeta = formatTMDBMetaResult(details, type); + if (!tmdbmeta) return null; + const meta = formatTMDBMeta(tmdbmeta, seasonData); + if (!meta) return null; + + return { + meta, + imdbId, + tmdbId: id, + }; +} + +export async function getLegacyMetaFromId( + type: MWMediaType, + id: string, + seasonId?: string ): Promise { const queryType = mediaTypeToJW(type); @@ -82,3 +179,55 @@ export async function getMetaFromId( tmdbId, }; } + +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; +} + +export async function convertLegacyUrl( + url: string +): Promise { + if (!isLegacyUrl(url)) return undefined; + + const urlParts = url.split("/").slice(2); + const [, type, id] = urlParts[0].split("-", 3); + + const mediaType = TMDBMediaToMediaType(type); + const meta = await getLegacyMetaFromId(mediaType, id); + + if (!meta) return undefined; + const { tmdbId, imdbId } = meta; + if (!tmdbId && !imdbId) return undefined; + + // 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 (tmdbId) { + return `/media/tmdb-${type}-${tmdbId}`; + } +} diff --git a/src/backend/metadata/justwatch.ts b/src/backend/metadata/justwatch.ts index 5c79c1e3..724c4acf 100644 --- a/src/backend/metadata/justwatch.ts +++ b/src/backend/metadata/justwatch.ts @@ -1,38 +1,10 @@ -import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types"; - -export const JW_API_BASE = "https://apis.justwatch.com"; -export const JW_IMAGE_BASE = "https://images.justwatch.com"; - -export type JWContentTypes = "movie" | "show"; - -export type JWSeasonShort = { - title: string; - id: number; - season_number: number; -}; - -export type JWEpisodeShort = { - title: string; - id: number; - episode_number: number; -}; - -export type JWMediaResult = { - title: string; - poster?: string; - id: number; - original_release_year?: number; - jw_entity_id: string; - object_type: JWContentTypes; - seasons?: JWSeasonShort[]; -}; - -export type JWSeasonMetaResult = { - title: string; - id: string; - season_number: number; - episodes: JWEpisodeShort[]; -}; +import { + JWContentTypes, + JWMediaResult, + JWSeasonMetaResult, + JW_IMAGE_BASE, +} from "./types/justwatch"; +import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types/mw"; export function mediaTypeToJW(type: MWMediaType): JWContentTypes { if (type === MWMediaType.MOVIE) return "movie"; diff --git a/src/backend/metadata/search.ts b/src/backend/metadata/search.ts index 10cbb285..0d8f561f 100644 --- a/src/backend/metadata/search.ts +++ b/src/backend/metadata/search.ts @@ -1,14 +1,12 @@ import { SimpleCache } from "@/utils/cache"; import { - JWContentTypes, - JWMediaResult, - JW_API_BASE, - formatJWMeta, - mediaTypeToJW, -} from "./justwatch"; -import { MWMediaMeta, MWQuery } from "./types"; -import { proxiedFetch } from "../helpers/fetch"; + formatTMDBMeta, + formatTMDBSearchResult, + mediaTypeToTMDB, + searchMedia, +} from "./tmdb"; +import { MWMediaMeta, MWQuery } from "./types/mw"; const cache = new SimpleCache(); cache.setCompare((a, b) => { @@ -16,44 +14,16 @@ cache.setCompare((a, b) => { }); cache.initialize(); -type JWSearchQuery = { - content_types: JWContentTypes[]; - page: number; - page_size: number; - query: string; -}; - -type JWPage = { - items: T[]; - page: number; - page_size: number; - total_pages: number; - total_results: number; -}; - export async function searchForMedia(query: MWQuery): Promise { if (cache.has(query)) return cache.get(query) as MWMediaMeta[]; const { searchQuery, type } = query; - const contentType = mediaTypeToJW(type); - const body: JWSearchQuery = { - content_types: [contentType], - page: 1, - query: searchQuery, - page_size: 40, - }; + const data = await searchMedia(searchQuery, mediaTypeToTMDB(type)); + const results = data.results.map((v) => { + const formattedResult = formatTMDBSearchResult(v, mediaTypeToTMDB(type)); + return formatTMDBMeta(formattedResult); + }); - const data = await proxiedFetch>( - "/content/titles/en_US/popular", - { - baseURL: JW_API_BASE, - params: { - body: JSON.stringify(body), - }, - } - ); - - const returnData = data.items.map((v) => formatJWMeta(v)); - cache.set(query, returnData, 3600); // cache for an hour - return returnData; + cache.set(query, results, 3600); // cache results for 1 hour + return results; } diff --git a/src/backend/metadata/tmdb.ts b/src/backend/metadata/tmdb.ts new file mode 100644 index 00000000..9b38d995 --- /dev/null +++ b/src/backend/metadata/tmdb.ts @@ -0,0 +1,239 @@ +import { conf } from "@/setup/config"; + +import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types/mw"; +import { + ExternalIdMovieSearchResult, + TMDBContentTypes, + TMDBEpisodeShort, + TMDBExternalIds, + TMDBMediaResult, + TMDBMovieData, + TMDBMovieExternalIds, + TMDBMovieResponse, + TMDBMovieResult, + TMDBSeason, + TMDBSeasonMetaResult, + TMDBShowData, + TMDBShowExternalIds, + TMDBShowResponse, + TMDBShowResult, +} from "./types/tmdb"; +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, + }; +} + +const baseURL = "https://api.themoviedb.org/3"; + +const headers = { + accept: "application/json", + Authorization: `Bearer ${conf().TMDB_READ_API_KEY}`, +}; + +async function get(url: string, params?: object): Promise { + const res = await mwFetch(encodeURI(url), { + headers, + baseURL, + params: { + ...params, + }, + }); + return res; +} + +export async function searchMedia( + query: string, + type: TMDBContentTypes +): Promise { + let data; + + switch (type) { + case "movie": + data = await get("search/movie", { + query, + include_adult: false, + language: "en-US", + page: 1, + }); + break; + case "show": + data = await get("search/tv", { + query, + include_adult: false, + language: "en-US", + page: 1, + }); + break; + default: + throw new Error("Invalid media type"); + } + + return data; +} + +// Conditional type which for inferring the return type based on the content type +type MediaDetailReturn = T extends "movie" + ? TMDBMovieData + : T extends "show" + ? TMDBShowData + : never; + +export function getMediaDetails< + T extends TMDBContentTypes, + TReturn = MediaDetailReturn +>(id: string, type: T): Promise { + if (type === "movie") { + return get(`/movie/${id}`); + } + if (type === "show") { + return get(`/tv/${id}`); + } + throw new Error("Invalid media type"); +} + +export function getMediaPoster(posterPath: string | null): string | undefined { + if (posterPath) return `https://image.tmdb.org/t/p/w185/${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, + })); +} + +export async function getExternalIds( + id: string, + type: TMDBContentTypes +): Promise { + let data; + + switch (type) { + case "movie": + data = await get(`/movie/${id}/external_ids`); + break; + case "show": + data = await get(`/tv/${id}/external_ids`); + break; + default: + throw new Error("Invalid media type"); + } + + return data; +} + +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: TMDBShowResult | TMDBMovieResult, + mediatype: TMDBContentTypes +): TMDBMediaResult { + const type = TMDBMediaToMediaType(mediatype); + if (type === MWMediaType.SERIES) { + const show = result as TMDBShowResult; + return { + title: show.name, + poster: getMediaPoster(show.poster_path), + id: show.id, + original_release_year: new Date(show.first_air_date).getFullYear(), + object_type: mediatype, + }; + } + const movie = result as TMDBMovieResult; + + return { + title: movie.title, + poster: getMediaPoster(movie.poster_path), + id: movie.id, + original_release_year: new Date(movie.release_date).getFullYear(), + object_type: mediatype, + }; +} diff --git a/src/backend/metadata/types/justwatch.ts b/src/backend/metadata/types/justwatch.ts new file mode 100644 index 00000000..cb3ac092 --- /dev/null +++ b/src/backend/metadata/types/justwatch.ts @@ -0,0 +1,48 @@ +export type JWContentTypes = "movie" | "show"; + +export type JWSearchQuery = { + content_types: JWContentTypes[]; + page: number; + page_size: number; + query: string; +}; + +export type JWPage = { + items: T[]; + page: number; + page_size: number; + total_pages: number; + total_results: number; +}; + +export const JW_API_BASE = "https://apis.justwatch.com"; +export const JW_IMAGE_BASE = "https://images.justwatch.com"; + +export type JWSeasonShort = { + title: string; + id: number; + season_number: number; +}; + +export type JWEpisodeShort = { + title: string; + id: number; + episode_number: number; +}; + +export type JWMediaResult = { + title: string; + poster?: string; + id: number; + original_release_year?: number; + jw_entity_id: string; + object_type: JWContentTypes; + seasons?: JWSeasonShort[]; +}; + +export type JWSeasonMetaResult = { + title: string; + id: string; + season_number: number; + episodes: JWEpisodeShort[]; +}; diff --git a/src/backend/metadata/types.ts b/src/backend/metadata/types/mw.ts similarity index 89% rename from src/backend/metadata/types.ts rename to src/backend/metadata/types/mw.ts index 2723fbe7..e7cc26fe 100644 --- a/src/backend/metadata/types.ts +++ b/src/backend/metadata/types/mw.ts @@ -45,3 +45,9 @@ export interface MWQuery { searchQuery: string; type: MWMediaType; } + +export interface DetailedMeta { + meta: MWMediaMeta; + imdbId?: string; + tmdbId?: string; +} diff --git a/src/backend/metadata/types/tmdb.ts b/src/backend/metadata/types/tmdb.ts new file mode 100644 index 00000000..843786f4 --- /dev/null +++ b/src/backend/metadata/types/tmdb.ts @@ -0,0 +1,308 @@ +export type TMDBContentTypes = "movie" | "show"; + +export type TMDBSeasonShort = { + title: string; + id: number; + season_number: number; +}; + +export type TMDBEpisodeShort = { + title: string; + id: number; + episode_number: number; +}; + +export type TMDBMediaResult = { + title: string; + poster?: string; + id: number; + original_release_year?: number; + object_type: TMDBContentTypes; + seasons?: TMDBSeasonShort[]; +}; + +export type TMDBSeasonMetaResult = { + title: string; + id: string; + season_number: number; + episodes: TMDBEpisodeShort[]; +}; + +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 interface TMDBEpisodeResult { + season: number; + number: number; + title: string; + ids: { + trakt: number; + tvdb: number; + imdb: string; + 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 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 TMDBShowExternalIds { + id: number; + imdb_id: null | string; + freebase_mid: null | string; + freebase_id: null | string; + tvdb_id: number; + tvrage_id: null | string; + wikidata_id: null | string; + facebook_id: null | string; + instagram_id: null | string; + twitter_id: null | string; +} + +export interface TMDBMovieExternalIds { + id: number; + imdb_id: null | string; + wikidata_id: null | string; + facebook_id: null | string; + instagram_id: null | string; + twitter_id: null | string; +} + +export type TMDBExternalIds = TMDBShowExternalIds | TMDBMovieExternalIds; + +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[]; +} diff --git a/src/backend/providers/2embed.ts b/src/backend/providers/2embed.ts index 7cc8938e..507d5a2d 100644 --- a/src/backend/providers/2embed.ts +++ b/src/backend/providers/2embed.ts @@ -8,7 +8,7 @@ import { MWStreamQuality, MWStreamType, } from "../helpers/streams"; -import { MWMediaType } from "../metadata/types"; +import { MWMediaType } from "../metadata/types/mw"; const twoEmbedBase = "https://www.2embed.to"; diff --git a/src/backend/providers/flixhq.ts b/src/backend/providers/flixhq.ts index 376abd08..fd905019 100644 --- a/src/backend/providers/flixhq.ts +++ b/src/backend/providers/flixhq.ts @@ -7,7 +7,7 @@ import { import { mwFetch } from "../helpers/fetch"; import { registerProvider } from "../helpers/register"; import { MWCaption, MWStreamQuality, MWStreamType } from "../helpers/streams"; -import { MWMediaType } from "../metadata/types"; +import { MWMediaType } from "../metadata/types/mw"; const flixHqBase = "https://consumet-api-clone.vercel.app/meta/tmdb"; // instance stolen from streaminal :) diff --git a/src/backend/providers/gdriveplayer.ts b/src/backend/providers/gdriveplayer.ts index 5478b6ed..c184fea7 100644 --- a/src/backend/providers/gdriveplayer.ts +++ b/src/backend/providers/gdriveplayer.ts @@ -3,7 +3,7 @@ import { unpack } from "unpacker"; import { registerProvider } from "@/backend/helpers/register"; import { MWStreamQuality } from "@/backend/helpers/streams"; -import { MWMediaType } from "@/backend/metadata/types"; +import { MWMediaType } from "@/backend/metadata/types/mw"; import { proxiedFetch } from "../helpers/fetch"; diff --git a/src/backend/providers/gomovies.ts b/src/backend/providers/gomovies.ts index 9e22d095..ddd43509 100644 --- a/src/backend/providers/gomovies.ts +++ b/src/backend/providers/gomovies.ts @@ -1,14 +1,14 @@ import { MWEmbedType } from "../helpers/embed"; import { proxiedFetch } from "../helpers/fetch"; import { registerProvider } from "../helpers/register"; -import { MWMediaType } from "../metadata/types"; +import { MWMediaType } from "../metadata/types/mw"; const gomoviesBase = "https://gomovies.sx"; registerProvider({ id: "gomovies", displayName: "GOmovies", - rank: 300, + rank: 200, type: [MWMediaType.MOVIE, MWMediaType.SERIES], async scrape({ media, episode }) { diff --git a/src/backend/providers/hdwatched.ts b/src/backend/providers/hdwatched.ts index 2096e160..458c3424 100644 --- a/src/backend/providers/hdwatched.ts +++ b/src/backend/providers/hdwatched.ts @@ -2,7 +2,7 @@ import { proxiedFetch } from "../helpers/fetch"; import { MWProviderContext } from "../helpers/provider"; import { registerProvider } from "../helpers/register"; import { MWStreamQuality, MWStreamType } from "../helpers/streams"; -import { MWMediaType } from "../metadata/types"; +import { MWMediaType } from "../metadata/types/mw"; const hdwatchedBase = "https://www.hdwatched.xyz"; diff --git a/src/backend/providers/kissasian.ts b/src/backend/providers/kissasian.ts index 90708970..a95e05ab 100644 --- a/src/backend/providers/kissasian.ts +++ b/src/backend/providers/kissasian.ts @@ -1,7 +1,7 @@ import { MWEmbedType } from "../helpers/embed"; import { proxiedFetch } from "../helpers/fetch"; import { registerProvider } from "../helpers/register"; -import { MWMediaType } from "../metadata/types"; +import { MWMediaType } from "../metadata/types/mw"; const kissasianBase = "https://kissasian.li"; diff --git a/src/backend/providers/m4ufree.ts b/src/backend/providers/m4ufree.ts index 0fe5303d..b9d5aef0 100644 --- a/src/backend/providers/m4ufree.ts +++ b/src/backend/providers/m4ufree.ts @@ -2,7 +2,7 @@ import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed"; import { proxiedFetch } from "../helpers/fetch"; import { registerProvider } from "../helpers/register"; -import { MWMediaType } from "../metadata/types"; +import { MWMediaType } from "../metadata/types/mw"; const HOST = "m4ufree.com"; const URL_BASE = `https://${HOST}`; diff --git a/src/backend/providers/netfilm.ts b/src/backend/providers/netfilm.ts index f7efcfbe..54016733 100644 --- a/src/backend/providers/netfilm.ts +++ b/src/backend/providers/netfilm.ts @@ -5,7 +5,7 @@ import { MWStreamQuality, MWStreamType, } from "../helpers/streams"; -import { MWMediaType } from "../metadata/types"; +import { MWMediaType } from "../metadata/types/mw"; const netfilmBase = "https://net-film.vercel.app"; diff --git a/src/backend/providers/remotestream.ts b/src/backend/providers/remotestream.ts index 02c0f199..093069e8 100644 --- a/src/backend/providers/remotestream.ts +++ b/src/backend/providers/remotestream.ts @@ -1,7 +1,7 @@ import { mwFetch } from "@/backend/helpers/fetch"; import { registerProvider } from "@/backend/helpers/register"; import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; -import { MWMediaType } from "@/backend/metadata/types"; +import { MWMediaType } from "@/backend/metadata/types/mw"; const remotestreamBase = `https://fsa.remotestre.am`; diff --git a/src/backend/providers/sflix.ts b/src/backend/providers/sflix.ts index 4121046b..2cb1c598 100644 --- a/src/backend/providers/sflix.ts +++ b/src/backend/providers/sflix.ts @@ -1,7 +1,7 @@ import { proxiedFetch } from "../helpers/fetch"; import { registerProvider } from "../helpers/register"; import { MWStreamQuality, MWStreamType } from "../helpers/streams"; -import { MWMediaType } from "../metadata/types"; +import { MWMediaType } from "../metadata/types/mw"; const sflixBase = "https://sflix.video"; diff --git a/src/backend/providers/streamflix.ts b/src/backend/providers/streamflix.ts index 90dd4975..d4488b03 100644 --- a/src/backend/providers/streamflix.ts +++ b/src/backend/providers/streamflix.ts @@ -5,7 +5,7 @@ import { MWStreamQuality, MWStreamType, } from "@/backend/helpers/streams"; -import { MWMediaType } from "@/backend/metadata/types"; +import { MWMediaType } from "@/backend/metadata/types/mw"; const streamflixBase = "https://us-west2-compute-proxied.streamflix.one"; diff --git a/src/backend/providers/superstream/index.ts b/src/backend/providers/superstream/index.ts index 585d8d8a..5af85cb9 100644 --- a/src/backend/providers/superstream/index.ts +++ b/src/backend/providers/superstream/index.ts @@ -13,7 +13,7 @@ import { MWStreamQuality, MWStreamType, } from "@/backend/helpers/streams"; -import { MWMediaType } from "@/backend/metadata/types"; +import { MWMediaType } from "@/backend/metadata/types/mw"; import { compareTitle } from "@/utils/titleMatch"; const nanoid = customAlphabet("0123456789abcdef", 32); @@ -142,7 +142,7 @@ const convertSubtitles = (subtitleGroup: any): MWCaption | null => { registerProvider({ id: "superstream", displayName: "Superstream", - rank: 200, + rank: 300, type: [MWMediaType.MOVIE, MWMediaType.SERIES], async scrape({ media, episode, progress }) { diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 4940cbc7..431de337 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { MWMediaType, MWQuery } from "@/backend/metadata/types"; +import { MWMediaType, MWQuery } from "@/backend/metadata/types/mw"; import { DropdownButton } from "./buttons/DropdownButton"; import { Icon, Icons } from "./Icon"; diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index 22865717..a153d8b4 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -1,8 +1,8 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; -import { JWMediaToId } from "@/backend/metadata/justwatch"; -import { MWMediaMeta } from "@/backend/metadata/types"; +import { TMDBMediaToId } from "@/backend/metadata/getmeta"; +import { MWMediaMeta } from "@/backend/metadata/types/mw"; import { DotList } from "@/components/text/DotList"; import { IconPatch } from "../buttons/IconPatch"; @@ -13,7 +13,7 @@ export interface MediaCardProps { linkable?: boolean; series?: { episode: number; - season: number; + season?: number; episodeId: string; seasonId: string; }; @@ -72,7 +72,7 @@ function MediaCardContent({ ].join(" ")} > {t("seasons.seasonAndEpisode", { - season: series.season, + season: series.season || 1, episode: series.episode, })}

@@ -132,12 +132,17 @@ export function MediaCard(props: MediaCardProps) { const canLink = props.linkable && !props.closable; let link = canLink - ? `/media/${encodeURIComponent(JWMediaToId(props.media))}` + ? `/media/${encodeURIComponent(TMDBMediaToId(props.media))}` : "#"; - if (canLink && props.series) - link += `/${encodeURIComponent(props.series.seasonId)}/${encodeURIComponent( - props.series.episodeId - )}`; + if (canLink && props.series) { + if (props.series.season === 0 && !props.series.episodeId) { + link += `/${encodeURIComponent(props.series.seasonId)}`; + } else { + link += `/${encodeURIComponent( + props.series.seasonId + )}/${encodeURIComponent(props.series.episodeId)}`; + } + } if (!props.linkable) return {content}; return ( diff --git a/src/components/media/WatchedMediaCard.tsx b/src/components/media/WatchedMediaCard.tsx index 346c77b6..ade1612a 100644 --- a/src/components/media/WatchedMediaCard.tsx +++ b/src/components/media/WatchedMediaCard.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; -import { MWMediaMeta } from "@/backend/metadata/types"; +import { MWMediaMeta } from "@/backend/metadata/types/mw"; import { useWatchedContext } from "@/state/watched"; import { MediaCard } from "./MediaCard"; diff --git a/src/hooks/useScrape.ts b/src/hooks/useScrape.ts index a375e618..3cffa4ee 100644 --- a/src/hooks/useScrape.ts +++ b/src/hooks/useScrape.ts @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { findBestStream } from "@/backend/helpers/scrape"; import { MWStream } from "@/backend/helpers/streams"; import { DetailedMeta } from "@/backend/metadata/getmeta"; -import { MWMediaType } from "@/backend/metadata/types"; +import { MWMediaType } from "@/backend/metadata/types/mw"; export interface ScrapeEventLog { type: "provider" | "embed"; diff --git a/src/hooks/useSearchQuery.ts b/src/hooks/useSearchQuery.ts index d431a0d0..cb8c3171 100644 --- a/src/hooks/useSearchQuery.ts +++ b/src/hooks/useSearchQuery.ts @@ -1,7 +1,7 @@ import { useState } from "react"; import { generatePath, useHistory, useRouteMatch } from "react-router-dom"; -import { MWMediaType, MWQuery } from "@/backend/metadata/types"; +import { MWMediaType, MWQuery } from "@/backend/metadata/types/mw"; function getInitialValue(params: { type: string; query: string }) { const type = diff --git a/src/index.tsx b/src/index.tsx index 1bf99f70..839d7a90 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,14 +7,15 @@ import { registerSW } from "virtual:pwa-register"; import { ErrorBoundary } from "@/components/layout/ErrorBoundary"; import App from "@/setup/App"; -import { conf } from "@/setup/config"; +import { assertConfig, conf } from "@/setup/config"; +import i18n from "@/setup/i18n"; import "@/setup/ga"; import "@/setup/sentry"; -import "@/setup/i18n"; import "@/setup/index.css"; import "@/backend"; import { initializeChromecast } from "./setup/chromecast"; +import { SettingsStore } from "./state/settings/store"; import { initializeStores } from "./utils/storage"; // initialize @@ -29,7 +30,9 @@ registerSW({ }); const LazyLoadedApp = React.lazy(async () => { + await assertConfig(); await initializeStores(); + i18n.changeLanguage(SettingsStore.get().language ?? "en"); return { default: App, }; diff --git a/src/setup/App.tsx b/src/setup/App.tsx index 992549e0..7d1847ae 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -1,7 +1,14 @@ -import { lazy } from "react"; -import { Redirect, Route, Switch } from "react-router-dom"; +import { ReactElement, lazy, useEffect } from "react"; +import { + Redirect, + Route, + Switch, + useHistory, + useLocation, +} from "react-router-dom"; -import { MWMediaType } from "@/backend/metadata/types"; +import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta"; +import { MWMediaType } from "@/backend/metadata/types/mw"; import { BannerContextProvider } from "@/hooks/useBanner"; import { Layout } from "@/setup/Layout"; import { BookmarkContextProvider } from "@/state/bookmark"; @@ -12,6 +19,22 @@ import { NotFoundPage } from "@/views/notfound/NotFoundView"; import { V2MigrationView } from "@/views/other/v2Migration"; import { SearchView } from "@/views/search/SearchView"; +function LegacyUrlView({ children }: { children: ReactElement }) { + const location = useLocation(); + const { replace } = useHistory(); + + useEffect(() => { + const url = location.pathname; + if (!isLegacyUrl(url)) return; + convertLegacyUrl(location.pathname).then((convertedUrl) => { + replace(convertedUrl ?? "/"); + }); + }, [location.pathname, replace]); + + if (isLegacyUrl(location.pathname)) return null; + return children; +} + function App() { return ( @@ -27,12 +50,16 @@ function App() { {/* pages */} - - + + + + + + + + + + = { - OMDB_API_KEY: import.meta.env.VITE_OMDB_API_KEY, - TMDB_API_KEY: import.meta.env.VITE_TMDB_API_KEY, + TMDB_READ_API_KEY: import.meta.env.VITE_TMDB_READ_API_KEY, APP_VERSION: undefined, GITHUB_LINK: undefined, DISCORD_LINK: undefined, @@ -30,25 +27,28 @@ const env: Record = { NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER, }; -const alerts = [] as string[]; - // loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js) -function getKey(key: keyof Config, defaultString?: string): string { +function getKeyValue(key: keyof Config): string | undefined { let windowValue = (window as any)?.__CONFIG__?.[`VITE_${key}`]; if (windowValue !== undefined && windowValue.length === 0) windowValue = undefined; - const value = env[key] ?? windowValue ?? undefined; - if (value === undefined) { - if (defaultString) return defaultString; - if (!alerts.includes(key)) { - // eslint-disable-next-line no-alert - window.alert(`Misconfigured instance, missing key: ${key}`); - alerts.push(key); - } - return ""; - } + return env[key] ?? windowValue ?? undefined; +} - return value; +function getKey(key: keyof Config, defaultString?: string): string { + return getKeyValue(key) ?? defaultString ?? ""; +} + +export function assertConfig() { + const keys: Array = ["TMDB_READ_API_KEY", "CORS_PROXY_URL"]; + const values = keys.map((key) => { + const val = getKeyValue(key); + if (val) return val; + // eslint-disable-next-line no-alert + window.alert(`Misconfigured instance, missing key: ${key}`); + return val; + }); + if (values.includes(undefined)) throw new Error("Misconfigured instance"); } export function conf(): RuntimeConfig { @@ -56,8 +56,7 @@ export function conf(): RuntimeConfig { APP_VERSION, GITHUB_LINK, DISCORD_LINK, - OMDB_API_KEY: getKey("OMDB_API_KEY"), - TMDB_API_KEY: getKey("TMDB_API_KEY"), + TMDB_READ_API_KEY: getKey("TMDB_READ_API_KEY"), PROXY_URLS: getKey("CORS_PROXY_URL") .split(",") .map((v) => v.trim()), diff --git a/src/state/bookmark/context.tsx b/src/state/bookmark/context.tsx index 9dca821f..692d4e76 100644 --- a/src/state/bookmark/context.tsx +++ b/src/state/bookmark/context.tsx @@ -1,6 +1,6 @@ import { ReactNode, createContext, useContext, useMemo } from "react"; -import { MWMediaMeta } from "@/backend/metadata/types"; +import { MWMediaMeta } from "@/backend/metadata/types/mw"; import { useStore } from "@/utils/storage"; import { BookmarkStore } from "./store"; diff --git a/src/state/bookmark/store.ts b/src/state/bookmark/store.ts index 1b7a2053..b2020020 100644 --- a/src/state/bookmark/store.ts +++ b/src/state/bookmark/store.ts @@ -2,6 +2,7 @@ import { createVersionedStore } from "@/utils/storage"; import { BookmarkStoreData } from "./types"; import { OldBookmarks, migrateV1Bookmarks } from "../watched/migrations/v2"; +import { migrateV2Bookmarks } from "../watched/migrations/v3"; export const BookmarkStore = createVersionedStore() .setKey("mw-bookmarks") @@ -13,6 +14,12 @@ export const BookmarkStore = createVersionedStore() }) .addVersion({ version: 1, + migrate(old: BookmarkStoreData) { + return migrateV2Bookmarks(old); + }, + }) + .addVersion({ + version: 2, create() { return { bookmarks: [], diff --git a/src/state/bookmark/types.ts b/src/state/bookmark/types.ts index 05cb3641..79b92a5c 100644 --- a/src/state/bookmark/types.ts +++ b/src/state/bookmark/types.ts @@ -1,4 +1,4 @@ -import { MWMediaMeta } from "@/backend/metadata/types"; +import { MWMediaMeta } from "@/backend/metadata/types/mw"; export interface BookmarkStoreData { bookmarks: MWMediaMeta[]; diff --git a/src/state/watched/context.tsx b/src/state/watched/context.tsx index 3ce17b2a..661b0ed3 100644 --- a/src/state/watched/context.tsx +++ b/src/state/watched/context.tsx @@ -8,7 +8,7 @@ import { } from "react"; import { DetailedMeta } from "@/backend/metadata/getmeta"; -import { MWMediaType } from "@/backend/metadata/types"; +import { MWMediaType } from "@/backend/metadata/types/mw"; import { useStore } from "@/utils/storage"; import { VideoProgressStore } from "./store"; diff --git a/src/state/watched/migrations/v2.ts b/src/state/watched/migrations/v2.ts index 8f7a56b6..94f1141b 100644 --- a/src/state/watched/migrations/v2.ts +++ b/src/state/watched/migrations/v2.ts @@ -1,6 +1,6 @@ import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta"; import { searchForMedia } from "@/backend/metadata/search"; -import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types"; +import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types/mw"; import { compareTitle } from "@/utils/titleMatch"; import { WatchedStoreData, WatchedStoreItem } from "../types"; diff --git a/src/state/watched/migrations/v3.ts b/src/state/watched/migrations/v3.ts new file mode 100644 index 00000000..dffae637 --- /dev/null +++ b/src/state/watched/migrations/v3.ts @@ -0,0 +1,89 @@ +import { getLegacyMetaFromId } from "@/backend/metadata/getmeta"; +import { + getEpisodes, + getMediaDetails, + getMovieFromExternalId, +} from "@/backend/metadata/tmdb"; +import { MWMediaType } from "@/backend/metadata/types/mw"; +import { BookmarkStoreData } from "@/state/bookmark/types"; +import { isNotNull } from "@/utils/typeguard"; + +import { WatchedStoreData } from "../types"; + +async function migrateId( + id: string, + type: MWMediaType +): Promise { + const meta = await getLegacyMetaFromId(type, id); + + if (!meta) return undefined; + const { tmdbId, imdbId } = meta; + if (!tmdbId && !imdbId) return undefined; + + // movies always have an imdb id on tmdb + if (imdbId && type === MWMediaType.MOVIE) { + const movieId = await getMovieFromExternalId(imdbId); + if (movieId) return movieId; + } + + if (tmdbId) { + return tmdbId; + } +} + +export async function migrateV2Bookmarks(old: BookmarkStoreData) { + const updatedBookmarks = old.bookmarks.map(async (item) => ({ + ...item, + id: await migrateId(item.id, item.type).catch(() => undefined), + })); + + return { + bookmarks: (await Promise.all(updatedBookmarks)).filter((item) => item.id), + }; +} + +export async function migrateV3Videos( + old: WatchedStoreData +): Promise { + const updatedItems = await Promise.all( + old.items.map(async (progress) => { + try { + const migratedId = await migrateId( + progress.item.meta.id, + progress.item.meta.type + ); + + if (!migratedId) return null; + + const clone = structuredClone(progress); + clone.item.meta.id = migratedId; + if (clone.item.series) { + const series = clone.item.series; + const details = await getMediaDetails(migratedId, "show"); + + const season = details.seasons.find( + (v) => v.season_number === series.season + ); + if (!season) return null; + + const episodes = await getEpisodes(migratedId, season.season_number); + const episode = episodes.find( + (v) => v.episode_number === series.episode + ); + if (!episode) return null; + + clone.item.series.episodeId = episode.id.toString(); + clone.item.series.seasonId = season.id.toString(); + } + + return clone; + } catch (err) { + return null; + } + }) + ); + + return { + items: updatedItems.filter(isNotNull), + }; +} diff --git a/src/state/watched/store.ts b/src/state/watched/store.ts index 95adef28..c11e3f59 100644 --- a/src/state/watched/store.ts +++ b/src/state/watched/store.ts @@ -1,6 +1,7 @@ import { createVersionedStore } from "@/utils/storage"; import { OldData, migrateV2Videos } from "./migrations/v2"; +import { migrateV3Videos } from "./migrations/v3"; import { WatchedStoreData } from "./types"; export const VideoProgressStore = createVersionedStore() @@ -21,6 +22,12 @@ export const VideoProgressStore = createVersionedStore() }) .addVersion({ version: 2, + migrate(old: WatchedStoreData) { + return migrateV3Videos(old); + }, + }) + .addVersion({ + version: 3, create() { return { items: [], diff --git a/src/state/watched/types.ts b/src/state/watched/types.ts index a3246c38..0854b90b 100644 --- a/src/state/watched/types.ts +++ b/src/state/watched/types.ts @@ -1,4 +1,4 @@ -import { MWMediaMeta } from "@/backend/metadata/types"; +import { MWMediaMeta } from "@/backend/metadata/types/mw"; export interface StoreMediaItem { meta: MWMediaMeta; diff --git a/src/utils/storage.ts b/src/utils/storage.ts index f48e0245..83057d54 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -46,8 +46,13 @@ export async function initializeStores() { let mostRecentData = data; try { for (const version of relevantVersions) { - if (version.migrate) + if (version.migrate) { + localStorage.setItem( + `BACKUP-v${version.version}-${internal.key}`, + JSON.stringify(mostRecentData) + ); mostRecentData = await version.migrate(mostRecentData); + } } } catch (err) { console.error(`FAILED TO MIGRATE STORE ${internal.key}`, err); diff --git a/src/utils/typeguard.ts b/src/utils/typeguard.ts new file mode 100644 index 00000000..95dd81a1 --- /dev/null +++ b/src/utils/typeguard.ts @@ -0,0 +1,3 @@ +export function isNotNull(obj: T | null): obj is T { + return obj != null; +} diff --git a/src/video/components/actions/DividerAction.tsx b/src/video/components/actions/DividerAction.tsx index 5778e16f..3aeaeaef 100644 --- a/src/video/components/actions/DividerAction.tsx +++ b/src/video/components/actions/DividerAction.tsx @@ -1,4 +1,4 @@ -import { MWMediaType } from "@/backend/metadata/types"; +import { MWMediaType } from "@/backend/metadata/types/mw"; import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useMeta } from "@/video/state/logic/meta"; diff --git a/src/video/components/actions/SeriesSelectionAction.tsx b/src/video/components/actions/SeriesSelectionAction.tsx index d228b047..9eff0bb6 100644 --- a/src/video/components/actions/SeriesSelectionAction.tsx +++ b/src/video/components/actions/SeriesSelectionAction.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; -import { MWMediaType } from "@/backend/metadata/types"; +import { MWMediaType } from "@/backend/metadata/types/mw"; import { Icons } from "@/components/Icon"; import { FloatingAnchor } from "@/components/popout/FloatingAnchor"; import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton"; diff --git a/src/video/components/controllers/MetaController.tsx b/src/video/components/controllers/MetaController.tsx index ee6bc696..25757e25 100644 --- a/src/video/components/controllers/MetaController.tsx +++ b/src/video/components/controllers/MetaController.tsx @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { MWCaption } from "@/backend/helpers/streams"; -import { MWSeasonWithEpisodeMeta } from "@/backend/metadata/types"; +import { MWSeasonWithEpisodeMeta } from "@/backend/metadata/types/mw"; import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useControls } from "@/video/state/logic/controls"; import { VideoPlayerMeta } from "@/video/state/types"; diff --git a/src/video/components/hooks/useCurrentSeriesEpisodeInfo.ts b/src/video/components/hooks/useCurrentSeriesEpisodeInfo.ts index 6eb51170..11dfdc88 100644 --- a/src/video/components/hooks/useCurrentSeriesEpisodeInfo.ts +++ b/src/video/components/hooks/useCurrentSeriesEpisodeInfo.ts @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { MWMediaType } from "@/backend/metadata/types"; +import { MWMediaType } from "@/backend/metadata/types/mw"; import { useMeta } from "@/video/state/logic/meta"; export function useCurrentSeriesEpisodeInfo(descriptor: string) { diff --git a/src/video/components/parts/VideoErrorBoundary.tsx b/src/video/components/parts/VideoErrorBoundary.tsx index 5786aa7a..061bf2b7 100644 --- a/src/video/components/parts/VideoErrorBoundary.tsx +++ b/src/video/components/parts/VideoErrorBoundary.tsx @@ -2,7 +2,7 @@ import { Component } from "react"; import { Trans } from "react-i18next"; import type { ReactNode } from "react-router-dom/node_modules/@types/react/index"; -import { MWMediaMeta } from "@/backend/metadata/types"; +import { MWMediaMeta } from "@/backend/metadata/types/mw"; import { ErrorMessage } from "@/components/layout/ErrorBoundary"; import { Link } from "@/components/text/Link"; import { conf } from "@/setup/config"; diff --git a/src/video/components/parts/VideoPlayerHeader.tsx b/src/video/components/parts/VideoPlayerHeader.tsx index 8c026c49..3a333ee3 100644 --- a/src/video/components/parts/VideoPlayerHeader.tsx +++ b/src/video/components/parts/VideoPlayerHeader.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; -import { MWMediaMeta } from "@/backend/metadata/types"; +import { MWMediaMeta } from "@/backend/metadata/types/mw"; import { IconPatch } from "@/components/buttons/IconPatch"; import { Icon, Icons } from "@/components/Icon"; import { BrandPill } from "@/components/layout/BrandPill"; diff --git a/src/video/components/popouts/EpisodeSelectionPopout.tsx b/src/video/components/popouts/EpisodeSelectionPopout.tsx index c80045bd..66c9ae49 100644 --- a/src/video/components/popouts/EpisodeSelectionPopout.tsx +++ b/src/video/components/popouts/EpisodeSelectionPopout.tsx @@ -2,9 +2,11 @@ import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useParams } from "react-router-dom"; -import { getMetaFromId } from "@/backend/metadata/getmeta"; -import { decodeJWId } from "@/backend/metadata/justwatch"; -import { MWMediaType, MWSeasonWithEpisodeMeta } from "@/backend/metadata/types"; +import { decodeTMDBId, getMetaFromId } from "@/backend/metadata/getmeta"; +import { + MWMediaType, + MWSeasonWithEpisodeMeta, +} from "@/backend/metadata/types/mw"; import { IconPatch } from "@/components/buttons/IconPatch"; import { Icon, Icons } from "@/components/Icon"; import { Loading } from "@/components/layout/Loading"; @@ -45,7 +47,7 @@ export function EpisodeSelectionPopout() { seasonId: sId, season: undefined, }); - reqSeasonMeta(decodeJWId(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/developer/VideoTesterView.tsx b/src/views/developer/VideoTesterView.tsx index b192cd40..e1b3fa35 100644 --- a/src/views/developer/VideoTesterView.tsx +++ b/src/views/developer/VideoTesterView.tsx @@ -3,7 +3,7 @@ import { Helmet } from "react-helmet"; import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; import { DetailedMeta } from "@/backend/metadata/getmeta"; -import { MWMediaType } from "@/backend/metadata/types"; +import { MWMediaType } from "@/backend/metadata/types/mw"; import { Button } from "@/components/Button"; import { Dropdown } from "@/components/Dropdown"; import { Navigation } from "@/components/layout/Navigation"; diff --git a/src/views/media/MediaView.tsx b/src/views/media/MediaView.tsx index c55211c7..6e1659a6 100644 --- a/src/views/media/MediaView.tsx +++ b/src/views/media/MediaView.tsx @@ -4,9 +4,15 @@ import { useTranslation } from "react-i18next"; import { useHistory, useParams } from "react-router-dom"; import { MWStream } from "@/backend/helpers/streams"; -import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta"; -import { decodeJWId } from "@/backend/metadata/justwatch"; -import { MWMediaType, MWSeasonWithEpisodeMeta } from "@/backend/metadata/types"; +import { + DetailedMeta, + decodeTMDBId, + getMetaFromId, +} from "@/backend/metadata/getmeta"; +import { + MWMediaType, + MWSeasonWithEpisodeMeta, +} from "@/backend/metadata/types/mw"; import { IconPatch } from "@/components/buttons/IconPatch"; import { Icons } from "@/components/Icon"; import { Loading } from "@/components/layout/Loading"; @@ -181,7 +187,7 @@ export function MediaView() { const [selected, setSelected] = useState(null); const [exec, loading, error] = useLoading( async (mediaParams: string, seasonId?: string) => { - const data = decodeJWId(mediaParams); + const data = decodeTMDBId(mediaParams); if (!data) return null; return getMetaFromId(data.type, data.id, seasonId); } diff --git a/src/views/other/v2Migration.tsx b/src/views/other/v2Migration.tsx index 1334ae26..d0b05e42 100644 --- a/src/views/other/v2Migration.tsx +++ b/src/views/other/v2Migration.tsx @@ -1,7 +1,7 @@ import pako from "pako"; import { useEffect, useState } from "react"; -import { MWMediaType } from "@/backend/metadata/types"; +import { MWMediaType } from "@/backend/metadata/types/mw"; import { conf } from "@/setup/config"; function fromBinary(str: string): Uint8Array { diff --git a/src/views/search/SearchResultsPartial.tsx b/src/views/search/SearchResultsPartial.tsx index 5769338b..e7cfc509 100644 --- a/src/views/search/SearchResultsPartial.tsx +++ b/src/views/search/SearchResultsPartial.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; -import { MWQuery } from "@/backend/metadata/types"; +import { MWQuery } from "@/backend/metadata/types/mw"; import { useDebounce } from "@/hooks/useDebounce"; import { HomeView } from "./HomeView"; diff --git a/src/views/search/SearchResultsView.tsx b/src/views/search/SearchResultsView.tsx index 331d4f2d..f6507ef1 100644 --- a/src/views/search/SearchResultsView.tsx +++ b/src/views/search/SearchResultsView.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { searchForMedia } from "@/backend/metadata/search"; -import { MWMediaMeta, MWQuery } from "@/backend/metadata/types"; +import { MWMediaMeta, MWQuery } from "@/backend/metadata/types/mw"; import { IconPatch } from "@/components/buttons/IconPatch"; import { Icons } from "@/components/Icon"; import { SectionHeading } from "@/components/layout/SectionHeading";