mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-28 23:36:06 +00:00
bunch of todos
This commit is contained in:
parent
8e522e18d4
commit
52b063b10a
|
@ -15,6 +15,7 @@
|
|||
"json5": "^2.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"nanoid": "^4.0.0",
|
||||
"ofetch": "^1.0.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-i18next": "^12.1.1",
|
||||
|
|
|
@ -4,15 +4,13 @@
|
|||
},
|
||||
"search": {
|
||||
"loading": "Fetching your favourite shows...",
|
||||
"providersFailed": "{{fails}}/{{total}} providers failed!",
|
||||
"allResults": "That's all we have!",
|
||||
"noResults": "We couldn't find anything!",
|
||||
"allFailed": "All providers have failed!",
|
||||
"allFailed": "Failed to find media, try again!",
|
||||
"headingTitle": "Search results",
|
||||
"headingLink": "Back to home",
|
||||
"bookmarks": "Bookmarks",
|
||||
"continueWatching": "Continue Watching",
|
||||
"tagline": "Because watching legally is boring",
|
||||
"title": "What do you want to watch?",
|
||||
"placeholder": "What do you want to watch?"
|
||||
},
|
||||
|
|
|
@ -2,7 +2,6 @@ import { MWStream } from "./streams";
|
|||
|
||||
export enum MWEmbedType {
|
||||
OPENLOAD = "openload",
|
||||
ANOTHER = "another",
|
||||
}
|
||||
|
||||
export type MWEmbed = {
|
||||
|
@ -17,6 +16,7 @@ export type MWEmbedContext = {
|
|||
|
||||
export type MWEmbedScraper = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
for: MWEmbedType;
|
||||
rank: number;
|
||||
disabled?: boolean;
|
||||
|
|
35
src/backend/helpers/fetch.ts
Normal file
35
src/backend/helpers/fetch.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { conf } from "@/setup/config";
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
type P<T> = Parameters<typeof ofetch<T>>;
|
||||
type R<T> = ReturnType<typeof ofetch<T>>;
|
||||
|
||||
const baseFetch = ofetch.create({
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
export function makeUrl(url: string, data: Record<string, string>) {
|
||||
let parsedUrl: string = url;
|
||||
Object.entries(data).forEach(([k, v]) => {
|
||||
parsedUrl = parsedUrl.replace(`{${k}}`, encodeURIComponent(v));
|
||||
});
|
||||
return parsedUrl;
|
||||
}
|
||||
|
||||
export function mwFetch<T>(url: string, ops: P<T>[1]): R<T> {
|
||||
return baseFetch<T>(url, ops);
|
||||
}
|
||||
|
||||
export function proxiedFetch<T>(url: string, ops: P<T>[1]): R<T> {
|
||||
const parsedUrl = new URL(url);
|
||||
Object.entries(ops?.params ?? {}).forEach(([k, v]) => {
|
||||
parsedUrl.searchParams.set(k, v);
|
||||
});
|
||||
return baseFetch<T>(conf().BASE_PROXY_URL, {
|
||||
...ops,
|
||||
baseURL: undefined,
|
||||
params: {
|
||||
destination: parsedUrl.toString(),
|
||||
},
|
||||
});
|
||||
}
|
|
@ -8,13 +8,26 @@ export type MWProviderScrapeResult = {
|
|||
embeds: MWEmbed[];
|
||||
};
|
||||
|
||||
export type MWProviderContext = {
|
||||
type MWProviderBase = {
|
||||
progress(percentage: number): void;
|
||||
media: DetailedMeta;
|
||||
};
|
||||
type MWProviderTypeSpecific =
|
||||
| {
|
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||
episode?: undefined;
|
||||
season?: undefined;
|
||||
}
|
||||
| {
|
||||
type: MWMediaType.SERIES;
|
||||
episode: number;
|
||||
season: number;
|
||||
};
|
||||
export type MWProviderContext = MWProviderTypeSpecific & MWProviderBase;
|
||||
|
||||
export type MWProvider = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
rank: number;
|
||||
disabled?: boolean;
|
||||
type: MWMediaType[];
|
||||
|
|
|
@ -1,38 +1,60 @@
|
|||
import { MWProviderScrapeResult } from "./provider";
|
||||
import { MWProviderContext, MWProviderScrapeResult } from "./provider";
|
||||
import { getEmbedScraperByType, getProviders } from "./register";
|
||||
import { runEmbedScraper, runProvider } from "./run";
|
||||
import { MWStream } from "./streams";
|
||||
import { DetailedMeta } from "../metadata/getmeta";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
|
||||
interface MWProgressData {
|
||||
type: "embed" | "provider";
|
||||
id: string;
|
||||
eventId: string;
|
||||
percentage: number;
|
||||
errored: boolean;
|
||||
}
|
||||
interface MWNextData {
|
||||
id: string;
|
||||
eventId: string;
|
||||
type: "embed" | "provider";
|
||||
}
|
||||
|
||||
export interface MWProviderRunContext {
|
||||
type MWProviderRunContextBase = {
|
||||
media: DetailedMeta;
|
||||
onProgress?: (data: MWProgressData) => void;
|
||||
onNext?: (data: MWNextData) => void;
|
||||
}
|
||||
};
|
||||
type MWProviderRunContextTypeSpecific =
|
||||
| {
|
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||
episode: undefined;
|
||||
season: undefined;
|
||||
}
|
||||
| {
|
||||
type: MWMediaType.SERIES;
|
||||
episode: number;
|
||||
season: number;
|
||||
};
|
||||
|
||||
export type MWProviderRunContext = MWProviderRunContextBase &
|
||||
MWProviderRunContextTypeSpecific;
|
||||
|
||||
async function findBestEmbedStream(
|
||||
result: MWProviderScrapeResult,
|
||||
providerId: string,
|
||||
ctx: MWProviderRunContext
|
||||
): Promise<MWStream | null> {
|
||||
if (result.stream) return result.stream;
|
||||
|
||||
let embedNum = 0;
|
||||
for (const embed of result.embeds) {
|
||||
embedNum += 1;
|
||||
if (!embed.type) continue;
|
||||
const scraper = getEmbedScraperByType(embed.type);
|
||||
if (!scraper) throw new Error("Type for embed not found");
|
||||
|
||||
ctx.onNext?.({ id: scraper.id, type: "embed" });
|
||||
const eventId = [providerId, scraper.id, embedNum].join("|");
|
||||
|
||||
ctx.onNext?.({ id: scraper.id, type: "embed", eventId });
|
||||
|
||||
let stream: MWStream;
|
||||
try {
|
||||
|
@ -41,6 +63,7 @@ async function findBestEmbedStream(
|
|||
progress(num) {
|
||||
ctx.onProgress?.({
|
||||
errored: false,
|
||||
eventId,
|
||||
id: scraper.id,
|
||||
percentage: num,
|
||||
type: "embed",
|
||||
|
@ -50,6 +73,7 @@ async function findBestEmbedStream(
|
|||
} catch {
|
||||
ctx.onProgress?.({
|
||||
errored: true,
|
||||
eventId,
|
||||
id: scraper.id,
|
||||
percentage: 100,
|
||||
type: "embed",
|
||||
|
@ -59,6 +83,7 @@ async function findBestEmbedStream(
|
|||
|
||||
ctx.onProgress?.({
|
||||
errored: false,
|
||||
eventId,
|
||||
id: scraper.id,
|
||||
percentage: 100,
|
||||
type: "embed",
|
||||
|
@ -76,24 +101,48 @@ export async function findBestStream(
|
|||
const providers = getProviders();
|
||||
|
||||
for (const provider of providers) {
|
||||
ctx.onNext?.({ id: provider.id, type: "provider" });
|
||||
const eventId = provider.id;
|
||||
ctx.onNext?.({ id: provider.id, type: "provider", eventId });
|
||||
let result: MWProviderScrapeResult;
|
||||
try {
|
||||
result = await runProvider(provider, {
|
||||
media: ctx.media,
|
||||
progress(num) {
|
||||
ctx.onProgress?.({
|
||||
percentage: num,
|
||||
errored: false,
|
||||
id: provider.id,
|
||||
type: "provider",
|
||||
});
|
||||
},
|
||||
});
|
||||
let context: MWProviderContext;
|
||||
if (ctx.type === MWMediaType.SERIES) {
|
||||
context = {
|
||||
media: ctx.media,
|
||||
type: ctx.type,
|
||||
episode: ctx.episode,
|
||||
season: ctx.season,
|
||||
progress(num) {
|
||||
ctx.onProgress?.({
|
||||
percentage: num,
|
||||
eventId,
|
||||
errored: false,
|
||||
id: provider.id,
|
||||
type: "provider",
|
||||
});
|
||||
},
|
||||
};
|
||||
} else {
|
||||
context = {
|
||||
media: ctx.media,
|
||||
type: ctx.type,
|
||||
progress(num) {
|
||||
ctx.onProgress?.({
|
||||
percentage: num,
|
||||
eventId,
|
||||
errored: false,
|
||||
id: provider.id,
|
||||
type: "provider",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
result = await runProvider(provider, context);
|
||||
} catch (err) {
|
||||
ctx.onProgress?.({
|
||||
percentage: 100,
|
||||
errored: true,
|
||||
eventId,
|
||||
id: provider.id,
|
||||
type: "provider",
|
||||
});
|
||||
|
@ -103,11 +152,12 @@ export async function findBestStream(
|
|||
ctx.onProgress?.({
|
||||
errored: false,
|
||||
id: provider.id,
|
||||
eventId,
|
||||
percentage: 100,
|
||||
type: "provider",
|
||||
});
|
||||
|
||||
const stream = await findBestEmbedStream(result, ctx);
|
||||
const stream = await findBestEmbedStream(result, provider.id, ctx);
|
||||
if (!stream) continue;
|
||||
return stream;
|
||||
}
|
||||
|
|
|
@ -2,13 +2,10 @@ import { initializeScraperStore } from "./helpers/register";
|
|||
|
||||
// TODO backend system:
|
||||
// - caption support
|
||||
// - hooks to run all providers one by one
|
||||
// - move over old providers to new system
|
||||
// - implement jons providers/embedscrapers
|
||||
// - show/episode support
|
||||
|
||||
// providers
|
||||
// -- nothing here yet
|
||||
import "./providers/gdriveplayer";
|
||||
|
||||
// embeds
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import { formatJWMeta, JWMediaResult } from "./justwatch";
|
||||
import { FetchError } from "ofetch";
|
||||
import { makeUrl, mwFetch } from "../helpers/fetch";
|
||||
import {
|
||||
formatJWMeta,
|
||||
JWMediaResult,
|
||||
JW_API_BASE,
|
||||
mediaTypeToJW,
|
||||
} from "./justwatch";
|
||||
import { MWMediaMeta, MWMediaType } from "./types";
|
||||
|
||||
const JW_API_BASE = "https://apis.justwatch.com";
|
||||
|
||||
// http://localhost:5173/#/media/movie-439596/
|
||||
|
||||
type JWExternalIdType =
|
||||
| "eidr"
|
||||
| "imdb_latest"
|
||||
|
@ -31,18 +34,23 @@ export interface DetailedMeta {
|
|||
export async function getMetaFromId(
|
||||
type: MWMediaType,
|
||||
id: string
|
||||
): Promise<DetailedMeta> {
|
||||
let queryType = "";
|
||||
if (type === MWMediaType.MOVIE) queryType = "movie";
|
||||
else if (type === MWMediaType.SERIES) queryType = "show";
|
||||
else if (type === MWMediaType.ANIME)
|
||||
throw new Error("Anime search type is not supported");
|
||||
): Promise<DetailedMeta | null> {
|
||||
const queryType = mediaTypeToJW(type);
|
||||
|
||||
const data = await fetch(
|
||||
`${JW_API_BASE}/content/titles/${queryType}/${encodeURIComponent(
|
||||
id
|
||||
)}/locale/en_US`
|
||||
).then((res) => res.json() as Promise<JWDetailedMeta>);
|
||||
let data: JWDetailedMeta;
|
||||
try {
|
||||
const url = makeUrl("/content/titles/{type}/{id}/locale/en_US", {
|
||||
type: queryType,
|
||||
id,
|
||||
});
|
||||
data = await mwFetch<JWDetailedMeta>(url, { baseURL: JW_API_BASE });
|
||||
} catch (err) {
|
||||
if (err instanceof FetchError) {
|
||||
// 400 and 404 are treated as not found
|
||||
if (err.statusCode === 400 || err.statusCode === 404) return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const imdbId = data.external_ids.find(
|
||||
(v) => v.provider === "imdb_latest"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { MWMediaType } 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";
|
||||
|
||||
|
@ -32,10 +33,7 @@ export function formatJWMeta(media: JWMediaResult) {
|
|||
id: media.id.toString(),
|
||||
year: media.original_release_year.toString(),
|
||||
poster: media.poster
|
||||
? `https://images.justwatch.com${media.poster.replace(
|
||||
"{profile}",
|
||||
"s166"
|
||||
)}`
|
||||
? `${JW_IMAGE_BASE}${media.poster.replace("{profile}", "s166")}`
|
||||
: undefined,
|
||||
type,
|
||||
};
|
||||
|
|
|
@ -1,10 +1,19 @@
|
|||
import { SimpleCache } from "@/utils/cache";
|
||||
import { mwFetch } from "../helpers/fetch";
|
||||
import {
|
||||
formatJWMeta,
|
||||
JWContentTypes,
|
||||
JWMediaResult,
|
||||
JW_API_BASE,
|
||||
mediaTypeToJW,
|
||||
} from "./justwatch";
|
||||
import { MWMediaMeta, MWMediaType, MWQuery } from "./types";
|
||||
import { MWMediaMeta, MWQuery } from "./types";
|
||||
|
||||
const cache = new SimpleCache<MWQuery, MWMediaMeta[]>();
|
||||
cache.setCompare((a, b) => {
|
||||
return a.type === b.type && a.searchQuery.trim() === b.searchQuery.trim();
|
||||
});
|
||||
cache.initialize();
|
||||
|
||||
type JWSearchQuery = {
|
||||
content_types: JWContentTypes[];
|
||||
|
@ -21,26 +30,29 @@ type JWPage<T> = {
|
|||
total_results: number;
|
||||
};
|
||||
|
||||
export async function searchForMedia({
|
||||
searchQuery,
|
||||
type,
|
||||
}: MWQuery): Promise<MWMediaMeta[]> {
|
||||
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: [],
|
||||
content_types: [contentType],
|
||||
page: 1,
|
||||
query: searchQuery,
|
||||
page_size: 40,
|
||||
};
|
||||
if (type === MWMediaType.MOVIE) body.content_types.push("movie");
|
||||
else if (type === MWMediaType.SERIES) body.content_types.push("show");
|
||||
else if (type === MWMediaType.ANIME)
|
||||
throw new Error("Anime search type is not supported");
|
||||
|
||||
const data = await fetch(
|
||||
`${JW_API_BASE}/content/titles/en_US/popular?body=${encodeURIComponent(
|
||||
JSON.stringify(body)
|
||||
)}`
|
||||
).then((res) => res.json() as Promise<JWPage<JWMediaResult>>);
|
||||
const data = await mwFetch<JWPage<JWMediaResult>>(
|
||||
"/content/titles/en_US/popular",
|
||||
{
|
||||
baseURL: JW_API_BASE,
|
||||
params: {
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data.items.map<MWMediaMeta>((v) => formatJWMeta(v));
|
||||
const returnData = data.items.map<MWMediaMeta>((v) => formatJWMeta(v));
|
||||
cache.set(query, returnData, 3600); // cache for an hour
|
||||
return returnData;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { conf } from "@/setup/config";
|
||||
import { unpack } from "unpacker";
|
||||
import CryptoJS from "crypto-js";
|
||||
|
||||
import { registerProvider } from "@/backend/helpers/register";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
import { MWStreamQuality } from "@/backend/helpers/streams";
|
||||
|
||||
import { unpack } from "unpacker";
|
||||
import CryptoJS from "crypto-js";
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
|
||||
const format = {
|
||||
stringify: (cipher: any) => {
|
||||
|
@ -34,16 +34,20 @@ const format = {
|
|||
|
||||
registerProvider({
|
||||
id: "gdriveplayer",
|
||||
displayName: "gdriveplayer",
|
||||
rank: 69,
|
||||
type: [MWMediaType.MOVIE],
|
||||
|
||||
async scrape({ progress, media: { imdbId } }) {
|
||||
progress(10);
|
||||
const streamRes = await fetch(
|
||||
`${
|
||||
conf().CORS_PROXY_URL
|
||||
}https://database.gdriveplayer.us/player.php?imdb=${imdbId}`
|
||||
).then((d) => d.text());
|
||||
const streamRes = await proxiedFetch<string>(
|
||||
"https://database.gdriveplayer.us/player.php",
|
||||
{
|
||||
params: {
|
||||
imdb: imdbId,
|
||||
},
|
||||
}
|
||||
);
|
||||
progress(90);
|
||||
const page = new DOMParser().parseFromString(streamRes, "text/html");
|
||||
|
||||
|
@ -67,6 +71,7 @@ registerProvider({
|
|||
{ format }
|
||||
).toString(CryptoJS.enc.Utf8)
|
||||
);
|
||||
|
||||
// eslint-disable-next-line
|
||||
const sources = JSON.parse(
|
||||
JSON.stringify(
|
||||
|
|
|
@ -5,6 +5,62 @@ import { Link } from "@/components/text/Link";
|
|||
import { Title } from "@/components/text/Title";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
interface ErrorShowcaseProps {
|
||||
error: {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ErrorShowcase(props: ErrorShowcaseProps) {
|
||||
return (
|
||||
<div className="w-4xl mt-12 max-w-full rounded bg-denim-300 px-6 py-4">
|
||||
<p className="mb-1 break-words font-bold text-white">
|
||||
{props.error.name} - {props.error.description}
|
||||
</p>
|
||||
<p className="break-words">{props.error.path}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorMessageProps {
|
||||
error?: {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
};
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ErrorMessage(props: ErrorMessageProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full flex-col items-center justify-center px-4 py-12">
|
||||
<div className="flex flex-col items-center justify-start text-center">
|
||||
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
|
||||
<Title>Whoops, it broke</Title>
|
||||
{props.children ? (
|
||||
props.children
|
||||
) : (
|
||||
<p className="my-6 max-w-lg">
|
||||
The app encountered an error and wasn't able to recover, please
|
||||
report it to the{" "}
|
||||
<Link url={conf().DISCORD_LINK} newTab>
|
||||
Discord server
|
||||
</Link>{" "}
|
||||
or on{" "}
|
||||
<Link url={conf().GITHUB_LINK} newTab>
|
||||
GitHub
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{props.error ? <ErrorShowcase error={props.error} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: {
|
||||
|
@ -50,33 +106,6 @@ export class ErrorBoundary extends Component<
|
|||
render() {
|
||||
if (!this.state.hasError) return this.props.children as any;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full flex-col items-center justify-center px-4 py-12">
|
||||
<div className="flex flex-col items-center justify-start text-center">
|
||||
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
|
||||
<Title>Whoops, it broke</Title>
|
||||
<p className="my-6 max-w-lg">
|
||||
The app encountered an error and wasn't able to recover, please
|
||||
report it to the{" "}
|
||||
<Link url={conf().DISCORD_LINK} newTab>
|
||||
Discord server
|
||||
</Link>{" "}
|
||||
or on{" "}
|
||||
<Link url={conf().GITHUB_LINK} newTab>
|
||||
GitHub
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
{this.state.error ? (
|
||||
<div className="w-4xl mt-12 max-w-full rounded bg-denim-300 px-6 py-4">
|
||||
<p className="mb-1 break-words font-bold text-white">
|
||||
{this.state.error.name} - {this.state.error.description}
|
||||
</p>
|
||||
<p className="break-words">{this.state.error.path}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
return <ErrorMessage error={this.state.error} />;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ interface VideoPlayerHeaderProps {
|
|||
}
|
||||
|
||||
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
||||
const showDivider = props.title || props.onClick;
|
||||
const showDivider = props.title && props.onClick;
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="flex flex-1 items-center">
|
||||
|
|
12
src/hooks/useGoBack.ts
Normal file
12
src/hooks/useGoBack.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { useCallback } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
export function useGoBack() {
|
||||
const reactHistory = useHistory();
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (reactHistory.action !== "POP") reactHistory.goBack();
|
||||
else reactHistory.push("/");
|
||||
}, [reactHistory]);
|
||||
return goBack;
|
||||
}
|
|
@ -1,16 +1,30 @@
|
|||
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 { useEffect, useState } from "react";
|
||||
|
||||
export interface ScrapeEventLog {
|
||||
type: "provider" | "embed";
|
||||
errored: boolean;
|
||||
percentage: number;
|
||||
eventId: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function useScrape(meta: DetailedMeta) {
|
||||
export type SelectedMediaData =
|
||||
| {
|
||||
type: MWMediaType.SERIES;
|
||||
episode: number;
|
||||
season: number;
|
||||
}
|
||||
| {
|
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||
episode: undefined;
|
||||
season: undefined;
|
||||
};
|
||||
|
||||
export function useScrape(meta: DetailedMeta, selected: SelectedMediaData) {
|
||||
const [eventLog, setEventLog] = useState<ScrapeEventLog[]>([]);
|
||||
const [stream, setStream] = useState<MWStream | null>(null);
|
||||
const [pending, setPending] = useState(true);
|
||||
|
@ -22,12 +36,14 @@ export function useScrape(meta: DetailedMeta) {
|
|||
(async () => {
|
||||
const scrapedStream = await findBestStream({
|
||||
media: meta,
|
||||
...selected,
|
||||
onNext(ctx) {
|
||||
setEventLog((arr) => [
|
||||
...arr,
|
||||
{
|
||||
errored: false,
|
||||
id: ctx.id,
|
||||
eventId: ctx.eventId,
|
||||
type: ctx.type,
|
||||
percentage: 0,
|
||||
},
|
||||
|
@ -48,7 +64,7 @@ export function useScrape(meta: DetailedMeta) {
|
|||
setPending(false);
|
||||
setStream(scrapedStream);
|
||||
})();
|
||||
}, [meta]);
|
||||
}, [meta, selected]);
|
||||
|
||||
return {
|
||||
stream,
|
||||
|
|
|
@ -33,6 +33,9 @@ if (key) {
|
|||
// - devices: ipadOS
|
||||
// - features: HLS, error handling, preload interactions
|
||||
|
||||
// TODO general todos:
|
||||
// - localize everything
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
|
|
49
src/views/media/MediaErrorView.tsx
Normal file
49
src/views/media/MediaErrorView.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
||||
import { Link } from "@/components/text/Link";
|
||||
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
|
||||
import { useGoBack } from "@/hooks/useGoBack";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
export function MediaFetchErrorView() {
|
||||
const goBack = useGoBack();
|
||||
|
||||
return (
|
||||
<div className="h-screen flex-1">
|
||||
<div className="fixed inset-x-0 top-0 py-6 px-8">
|
||||
<VideoPlayerHeader onClick={goBack} />
|
||||
</div>
|
||||
<ErrorMessage>
|
||||
<p className="my-6 max-w-lg">
|
||||
We failed to request the media you asked for, check your internet
|
||||
connection and try again.
|
||||
</p>
|
||||
</ErrorMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaPlaybackErrorView(props: { title?: string }) {
|
||||
const goBack = useGoBack();
|
||||
|
||||
return (
|
||||
<div className="h-screen flex-1">
|
||||
<div className="fixed inset-x-0 top-0 py-6 px-8">
|
||||
<VideoPlayerHeader onClick={goBack} title={props.title} />
|
||||
</div>
|
||||
<ErrorMessage>
|
||||
<p className="my-6 max-w-lg">
|
||||
We encountered an error while playing the video you requested. If this
|
||||
keeps happening please report the issue to the
|
||||
<Link url={conf().DISCORD_LINK} newTab>
|
||||
Discord server
|
||||
</Link>{" "}
|
||||
or on{" "}
|
||||
<Link url={conf().GITHUB_LINK} newTab>
|
||||
GitHub
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</ErrorMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -71,7 +71,7 @@ export function MediaScrapeLog(props: MediaScrapeLogProps) {
|
|||
>
|
||||
<MediaScrapePillSkeleton />
|
||||
{props.events.map((v) => (
|
||||
<MediaScrapePill event={v} key={v.id} />
|
||||
<MediaScrapePill event={v} key={v.eventId} />
|
||||
))}
|
||||
<MediaScrapePillSkeleton />
|
||||
</div>
|
||||
|
|
|
@ -1,14 +1,21 @@
|
|||
import { useHistory, useParams } from "react-router-dom";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DecoratedVideoPlayer } from "@/components/video/DecoratedVideoPlayer";
|
||||
import { MWStream } from "@/backend/helpers/streams";
|
||||
import { useScrape } from "@/hooks/useScrape";
|
||||
import { SelectedMediaData, useScrape } from "@/hooks/useScrape";
|
||||
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
|
||||
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
|
||||
import { JWMediaToMediaType } from "@/backend/metadata/justwatch";
|
||||
import { SourceControl } from "@/components/video/controls/SourceControl";
|
||||
import { Loading } from "@/components/layout/Loading";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
import { useGoBack } from "@/hooks/useGoBack";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { MediaFetchErrorView } from "./MediaErrorView";
|
||||
import { MediaScrapeLog } from "./MediaScrapeLog";
|
||||
import { NotFoundMedia, NotFoundWrapper } from "../notfound/NotFoundView";
|
||||
|
||||
function MediaViewLoading(props: { onGoBack(): void }) {
|
||||
return (
|
||||
|
@ -28,9 +35,10 @@ interface MediaViewScrapingProps {
|
|||
onStream(stream: MWStream): void;
|
||||
onGoBack(): void;
|
||||
meta: DetailedMeta;
|
||||
selected: SelectedMediaData;
|
||||
}
|
||||
function MediaViewScraping(props: MediaViewScrapingProps) {
|
||||
const { eventLog, stream } = useScrape(props.meta);
|
||||
const { eventLog, stream, pending } = useScrape(props.meta, props.selected);
|
||||
|
||||
useEffect(() => {
|
||||
if (stream) {
|
||||
|
@ -38,8 +46,6 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
|
|||
}
|
||||
}, [stream, props]);
|
||||
|
||||
// TODO error screen if no streams found
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen items-center justify-center">
|
||||
<div className="absolute inset-x-0 top-0 py-6 px-8">
|
||||
|
@ -48,44 +54,91 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
|
|||
title={props.meta.meta.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<Loading className="mb-4" />
|
||||
<p className="mb-8 text-denim-700">Finding the best video for you</p>
|
||||
<MediaScrapeLog events={eventLog} />
|
||||
<div className="flex flex-col items-center transition-opacity duration-200">
|
||||
{pending ? (
|
||||
<>
|
||||
<Loading />
|
||||
<p className="mb-8 text-denim-700">
|
||||
Finding the best video for you
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconPatch icon={Icons.EYE_SLASH} className="mb-8 text-bink-700" />
|
||||
<p className="mb-8 text-denim-700">
|
||||
Whoops, could't find any videos for you
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={`flex flex-col items-center transition-opacity duration-200 ${
|
||||
pending ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<MediaScrapeLog events={eventLog} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaView() {
|
||||
const reactHistory = useHistory();
|
||||
const params = useParams<{ media: string }>();
|
||||
const goBack = useCallback(() => {
|
||||
if (reactHistory.action !== "POP") reactHistory.goBack();
|
||||
else reactHistory.push("/");
|
||||
}, [reactHistory]);
|
||||
const goBack = useGoBack();
|
||||
|
||||
const [meta, setMeta] = useState<DetailedMeta | null>(null);
|
||||
const [selected, setSelected] = useState<SelectedMediaData | null>(null);
|
||||
const [exec, loading, error] = useLoading(async (mediaParams: string) => {
|
||||
let type: MWMediaType;
|
||||
let id = "";
|
||||
try {
|
||||
const [t, i] = mediaParams.split("-", 2);
|
||||
type = JWMediaToMediaType(t);
|
||||
id = i;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
return getMetaFromId(type, id);
|
||||
});
|
||||
const [stream, setStream] = useState<MWStream | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO handle errors
|
||||
(async () => {
|
||||
const [t, id] = params.media.split("-", 2);
|
||||
const type = JWMediaToMediaType(t);
|
||||
const fetchedMeta = await getMetaFromId(type, id);
|
||||
setMeta(fetchedMeta);
|
||||
})();
|
||||
}, [setMeta, params]);
|
||||
exec(params.media).then((v) => {
|
||||
setMeta(v ?? null);
|
||||
if (v)
|
||||
setSelected({
|
||||
type: v.meta.type,
|
||||
episode: 0 as any,
|
||||
season: 0 as any,
|
||||
});
|
||||
else setSelected(null);
|
||||
});
|
||||
}, [exec, params.media]);
|
||||
|
||||
// TODO watched store
|
||||
// TODO error page with video header
|
||||
|
||||
if (!meta) return <MediaViewLoading onGoBack={goBack} />;
|
||||
if (loading) return <MediaViewLoading onGoBack={goBack} />;
|
||||
if (error) return <MediaFetchErrorView />;
|
||||
if (!meta || !selected)
|
||||
return (
|
||||
<NotFoundWrapper video>
|
||||
<NotFoundMedia />
|
||||
</NotFoundWrapper>
|
||||
);
|
||||
|
||||
// scraping view will start scraping and return with onStream
|
||||
if (!stream)
|
||||
return (
|
||||
<MediaViewScraping meta={meta} onGoBack={goBack} onStream={setStream} />
|
||||
<MediaViewScraping
|
||||
meta={meta}
|
||||
selected={selected}
|
||||
onGoBack={goBack}
|
||||
onStream={setStream}
|
||||
/>
|
||||
);
|
||||
|
||||
// show stream once we have a stream
|
||||
return (
|
||||
<div className="h-screen w-screen">
|
||||
<DecoratedVideoPlayer title={meta.meta.title} onGoBack={goBack} autoPlay>
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
import { ReactElement } from "react";
|
||||
|
||||
export interface NotFoundChecksProps {
|
||||
id: string;
|
||||
children?: ReactElement;
|
||||
}
|
||||
|
||||
/*
|
||||
** Component that only renders children if the passed in data is fully correct
|
||||
*/
|
||||
export function NotFoundChecks(
|
||||
props: NotFoundChecksProps
|
||||
): ReactElement | null {
|
||||
// TODO do notfound check
|
||||
|
||||
return props.children || null;
|
||||
}
|
|
@ -5,11 +5,24 @@ import { Icons } from "@/components/Icon";
|
|||
import { Navigation } from "@/components/layout/Navigation";
|
||||
import { ArrowLink } from "@/components/text/ArrowLink";
|
||||
import { Title } from "@/components/text/Title";
|
||||
import { useGoBack } from "@/hooks/useGoBack";
|
||||
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
|
||||
|
||||
export function NotFoundWrapper(props: {
|
||||
children?: ReactNode;
|
||||
video?: boolean;
|
||||
}) {
|
||||
const goBack = useGoBack();
|
||||
|
||||
function NotFoundWrapper(props: { children?: ReactNode }) {
|
||||
return (
|
||||
<div className="h-screen flex-1">
|
||||
<Navigation />
|
||||
{props.video ? (
|
||||
<div className="fixed inset-x-0 top-0 py-6 px-8">
|
||||
<VideoPlayerHeader onClick={goBack} />
|
||||
</div>
|
||||
) : (
|
||||
<Navigation />
|
||||
)}
|
||||
<div className="flex h-full flex-col items-center justify-center p-5 text-center">
|
||||
{props.children}
|
||||
</div>
|
||||
|
|
|
@ -52,7 +52,6 @@ export function SearchResultsView({ searchQuery }: { searchQuery: MWQuery }) {
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO use cache
|
||||
async function runSearch(query: MWQuery) {
|
||||
const searchResults = await runSearchQuery(query);
|
||||
if (!searchResults) return;
|
||||
|
|
24
yarn.lock
24
yarn.lock
|
@ -974,6 +974,11 @@
|
|||
"depd@^1.1.2":
|
||||
"version" "1.1.2"
|
||||
|
||||
"destr@^1.2.1":
|
||||
"integrity" "sha512-lrbCJwD9saUQrqUfXvl6qoM+QN3W7tLV5pAOs+OqOmopCCz/JkE05MHedJR1xfk4IAnZuJXPVuN5+7jNA2ZCiA=="
|
||||
"resolved" "https://registry.npmjs.org/destr/-/destr-1.2.2.tgz"
|
||||
"version" "1.2.2"
|
||||
|
||||
"detective@^5.2.1":
|
||||
"integrity" "sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw=="
|
||||
"resolved" "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz"
|
||||
|
@ -2363,6 +2368,11 @@
|
|||
"negotiator@^0.6.3":
|
||||
"version" "0.6.3"
|
||||
|
||||
"node-fetch-native@^1.0.1":
|
||||
"integrity" "sha512-VzW+TAk2wE4X9maiKMlT+GsPU4OMmR1U9CrHSmd3DFLn2IcZ9VJ6M6BBugGfYUnPCLSYxXdZy17M0BEJyhUTwg=="
|
||||
"resolved" "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.0.1.tgz"
|
||||
"version" "1.0.1"
|
||||
|
||||
"node-fetch@2.6.7":
|
||||
"integrity" "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="
|
||||
"resolved" "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz"
|
||||
|
@ -2631,6 +2641,15 @@
|
|||
"define-properties" "^1.1.4"
|
||||
"es-abstract" "^1.20.4"
|
||||
|
||||
"ofetch@^1.0.0":
|
||||
"integrity" "sha512-d40aof8czZFSQKJa4+F7Ch3UC5D631cK1TTUoK+iNEut9NoiCL+u0vykl/puYVUS2df4tIQl5upQcolIcEzQjQ=="
|
||||
"resolved" "https://registry.npmjs.org/ofetch/-/ofetch-1.0.0.tgz"
|
||||
"version" "1.0.0"
|
||||
dependencies:
|
||||
"destr" "^1.2.1"
|
||||
"node-fetch-native" "^1.0.1"
|
||||
"ufo" "^1.0.0"
|
||||
|
||||
"once@^1.3.0":
|
||||
"integrity" "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="
|
||||
"resolved" "https://registry.npmjs.org/once/-/once-1.4.0.tgz"
|
||||
|
@ -3430,6 +3449,11 @@
|
|||
"resolved" "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz"
|
||||
"version" "4.9.4"
|
||||
|
||||
"ufo@^1.0.0":
|
||||
"integrity" "sha512-boAm74ubXHY7KJQZLlXrtMz52qFvpsbOxDcZOnw/Wf+LS4Mmyu7JxmzD4tDLtUQtmZECypJ0FrCz4QIe6dvKRA=="
|
||||
"resolved" "https://registry.npmjs.org/ufo/-/ufo-1.0.1.tgz"
|
||||
"version" "1.0.1"
|
||||
|
||||
"unbox-primitive@^1.0.2":
|
||||
"integrity" "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw=="
|
||||
"resolved" "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz"
|
||||
|
|
Loading…
Reference in a new issue