mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-20 14:37:43 +01:00
Merge branch 'dev' into meta/code-of-conduct
This commit is contained in:
commit
d2d710ad37
57 changed files with 1021 additions and 179 deletions
|
@ -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.
|
||||
|
|
|
@ -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=...
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "movie-web",
|
||||
"version": "3.0.15",
|
||||
"version": "3.1.2",
|
||||
"private": true,
|
||||
"homepage": "https://movie-web.app",
|
||||
"dependencies": {
|
||||
|
|
|
@ -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: ""
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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[] = [
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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<DetailedMeta | null> {
|
||||
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<DetailedMeta | null> {
|
||||
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<string | undefined> {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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<MWQuery, MWMediaMeta[]>();
|
||||
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<T> = {
|
||||
items: T[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
};
|
||||
|
||||
export async function searchForMedia(query: MWQuery): Promise<MWMediaMeta[]> {
|
||||
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<JWPage<JWMediaResult>>(
|
||||
"/content/titles/en_US/popular",
|
||||
{
|
||||
baseURL: JW_API_BASE,
|
||||
params: {
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const returnData = data.items.map<MWMediaMeta>((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;
|
||||
}
|
||||
|
|
239
src/backend/metadata/tmdb.ts
Normal file
239
src/backend/metadata/tmdb.ts
Normal file
|
@ -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<T>(url: string, params?: object): Promise<T> {
|
||||
const res = await mwFetch<any>(encodeURI(url), {
|
||||
headers,
|
||||
baseURL,
|
||||
params: {
|
||||
...params,
|
||||
},
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function searchMedia(
|
||||
query: string,
|
||||
type: TMDBContentTypes
|
||||
): Promise<TMDBMovieResponse | TMDBShowResponse> {
|
||||
let data;
|
||||
|
||||
switch (type) {
|
||||
case "movie":
|
||||
data = await get<TMDBMovieResponse>("search/movie", {
|
||||
query,
|
||||
include_adult: false,
|
||||
language: "en-US",
|
||||
page: 1,
|
||||
});
|
||||
break;
|
||||
case "show":
|
||||
data = await get<TMDBShowResponse>("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 TMDBContentTypes> = T extends "movie"
|
||||
? TMDBMovieData
|
||||
: T extends "show"
|
||||
? TMDBShowData
|
||||
: never;
|
||||
|
||||
export function getMediaDetails<
|
||||
T extends TMDBContentTypes,
|
||||
TReturn = MediaDetailReturn<T>
|
||||
>(id: string, type: T): Promise<TReturn> {
|
||||
if (type === "movie") {
|
||||
return get<TReturn>(`/movie/${id}`);
|
||||
}
|
||||
if (type === "show") {
|
||||
return get<TReturn>(`/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<TMDBEpisodeShort[]> {
|
||||
const data = await get<TMDBSeason>(`/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<TMDBExternalIds> {
|
||||
let data;
|
||||
|
||||
switch (type) {
|
||||
case "movie":
|
||||
data = await get<TMDBMovieExternalIds>(`/movie/${id}/external_ids`);
|
||||
break;
|
||||
case "show":
|
||||
data = await get<TMDBShowExternalIds>(`/tv/${id}/external_ids`);
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid media type");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getMovieFromExternalId(
|
||||
imdbId: string
|
||||
): Promise<string | undefined> {
|
||||
const data = await get<ExternalIdMovieSearchResult>(`/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,
|
||||
};
|
||||
}
|
48
src/backend/metadata/types/justwatch.ts
Normal file
48
src/backend/metadata/types/justwatch.ts
Normal file
|
@ -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<T> = {
|
||||
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[];
|
||||
};
|
|
@ -45,3 +45,9 @@ export interface MWQuery {
|
|||
searchQuery: string;
|
||||
type: MWMediaType;
|
||||
}
|
||||
|
||||
export interface DetailedMeta {
|
||||
meta: MWMediaMeta;
|
||||
imdbId?: string;
|
||||
tmdbId?: string;
|
||||
}
|
308
src/backend/metadata/types/tmdb.ts
Normal file
308
src/backend/metadata/types/tmdb.ts
Normal file
|
@ -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[];
|
||||
}
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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 :)
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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 }) {
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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}`;
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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`;
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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 }) {
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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,
|
||||
})}
|
||||
</p>
|
||||
|
@ -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 <span>{content}</span>;
|
||||
return (
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
<SettingsProvider>
|
||||
|
@ -27,12 +50,16 @@ function App() {
|
|||
</Route>
|
||||
|
||||
{/* pages */}
|
||||
<Route exact path="/media/:media" component={MediaView} />
|
||||
<Route
|
||||
exact
|
||||
path="/media/:media/:season/:episode"
|
||||
component={MediaView}
|
||||
/>
|
||||
<Route exact path="/media/:media">
|
||||
<LegacyUrlView>
|
||||
<MediaView />
|
||||
</LegacyUrlView>
|
||||
</Route>
|
||||
<Route exact path="/media/:media/:season/:episode">
|
||||
<LegacyUrlView>
|
||||
<MediaView />
|
||||
</LegacyUrlView>
|
||||
</Route>
|
||||
<Route
|
||||
exact
|
||||
path="/search/:type/:query?"
|
||||
|
|
|
@ -4,8 +4,7 @@ interface Config {
|
|||
APP_VERSION: string;
|
||||
GITHUB_LINK: string;
|
||||
DISCORD_LINK: string;
|
||||
OMDB_API_KEY: string;
|
||||
TMDB_API_KEY: string;
|
||||
TMDB_READ_API_KEY: string;
|
||||
CORS_PROXY_URL: string;
|
||||
NORMAL_ROUTER: boolean;
|
||||
}
|
||||
|
@ -14,15 +13,13 @@ export interface RuntimeConfig {
|
|||
APP_VERSION: string;
|
||||
GITHUB_LINK: string;
|
||||
DISCORD_LINK: string;
|
||||
OMDB_API_KEY: string;
|
||||
TMDB_API_KEY: string;
|
||||
TMDB_READ_API_KEY: string;
|
||||
NORMAL_ROUTER: boolean;
|
||||
PROXY_URLS: string[];
|
||||
}
|
||||
|
||||
const env: Record<keyof Config, undefined | string> = {
|
||||
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<keyof Config, undefined | string> = {
|
|||
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<keyof Config> = ["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()),
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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<BookmarkStoreData>()
|
||||
.setKey("mw-bookmarks")
|
||||
|
@ -13,6 +14,12 @@ export const BookmarkStore = createVersionedStore<BookmarkStoreData>()
|
|||
})
|
||||
.addVersion({
|
||||
version: 1,
|
||||
migrate(old: BookmarkStoreData) {
|
||||
return migrateV2Bookmarks(old);
|
||||
},
|
||||
})
|
||||
.addVersion({
|
||||
version: 2,
|
||||
create() {
|
||||
return {
|
||||
bookmarks: [],
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
||||
|
||||
export interface BookmarkStoreData {
|
||||
bookmarks: MWMediaMeta[];
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
89
src/state/watched/migrations/v3.ts
Normal file
89
src/state/watched/migrations/v3.ts
Normal file
|
@ -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<string | undefined> {
|
||||
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<WatchedStoreData> {
|
||||
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),
|
||||
};
|
||||
}
|
|
@ -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<WatchedStoreData>()
|
||||
|
@ -21,6 +22,12 @@ export const VideoProgressStore = createVersionedStore<WatchedStoreData>()
|
|||
})
|
||||
.addVersion({
|
||||
version: 2,
|
||||
migrate(old: WatchedStoreData) {
|
||||
return migrateV3Videos(old);
|
||||
},
|
||||
})
|
||||
.addVersion({
|
||||
version: 3,
|
||||
create() {
|
||||
return {
|
||||
items: [],
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
||||
|
||||
export interface StoreMediaItem {
|
||||
meta: MWMediaMeta;
|
||||
|
|
|
@ -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);
|
||||
|
|
3
src/utils/typeguard.ts
Normal file
3
src/utils/typeguard.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export function isNotNull<T>(obj: T | null): obj is T {
|
||||
return obj != null;
|
||||
}
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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<SelectedMediaData | null>(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);
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
Loading…
Reference in a new issue