bunch of todos

This commit is contained in:
Jelle van Snik 2023-01-15 16:01:07 +01:00
parent 8e522e18d4
commit 52b063b10a
23 changed files with 445 additions and 147 deletions

View File

@ -15,6 +15,7 @@
"json5": "^2.2.0", "json5": "^2.2.0",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"nanoid": "^4.0.0", "nanoid": "^4.0.0",
"ofetch": "^1.0.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-i18next": "^12.1.1", "react-i18next": "^12.1.1",

View File

@ -4,15 +4,13 @@
}, },
"search": { "search": {
"loading": "Fetching your favourite shows...", "loading": "Fetching your favourite shows...",
"providersFailed": "{{fails}}/{{total}} providers failed!",
"allResults": "That's all we have!", "allResults": "That's all we have!",
"noResults": "We couldn't find anything!", "noResults": "We couldn't find anything!",
"allFailed": "All providers have failed!", "allFailed": "Failed to find media, try again!",
"headingTitle": "Search results", "headingTitle": "Search results",
"headingLink": "Back to home", "headingLink": "Back to home",
"bookmarks": "Bookmarks", "bookmarks": "Bookmarks",
"continueWatching": "Continue Watching", "continueWatching": "Continue Watching",
"tagline": "Because watching legally is boring",
"title": "What do you want to watch?", "title": "What do you want to watch?",
"placeholder": "What do you want to watch?" "placeholder": "What do you want to watch?"
}, },

View File

@ -2,7 +2,6 @@ import { MWStream } from "./streams";
export enum MWEmbedType { export enum MWEmbedType {
OPENLOAD = "openload", OPENLOAD = "openload",
ANOTHER = "another",
} }
export type MWEmbed = { export type MWEmbed = {
@ -17,6 +16,7 @@ export type MWEmbedContext = {
export type MWEmbedScraper = { export type MWEmbedScraper = {
id: string; id: string;
displayName: string;
for: MWEmbedType; for: MWEmbedType;
rank: number; rank: number;
disabled?: boolean; disabled?: boolean;

View 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(),
},
});
}

View File

@ -8,13 +8,26 @@ export type MWProviderScrapeResult = {
embeds: MWEmbed[]; embeds: MWEmbed[];
}; };
export type MWProviderContext = { type MWProviderBase = {
progress(percentage: number): void; progress(percentage: number): void;
media: DetailedMeta; 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 = { export type MWProvider = {
id: string; id: string;
displayName: string;
rank: number; rank: number;
disabled?: boolean; disabled?: boolean;
type: MWMediaType[]; type: MWMediaType[];

View File

@ -1,38 +1,60 @@
import { MWProviderScrapeResult } from "./provider"; import { MWProviderContext, MWProviderScrapeResult } from "./provider";
import { getEmbedScraperByType, getProviders } from "./register"; import { getEmbedScraperByType, getProviders } from "./register";
import { runEmbedScraper, runProvider } from "./run"; import { runEmbedScraper, runProvider } from "./run";
import { MWStream } from "./streams"; import { MWStream } from "./streams";
import { DetailedMeta } from "../metadata/getmeta"; import { DetailedMeta } from "../metadata/getmeta";
import { MWMediaType } from "../metadata/types";
interface MWProgressData { interface MWProgressData {
type: "embed" | "provider"; type: "embed" | "provider";
id: string; id: string;
eventId: string;
percentage: number; percentage: number;
errored: boolean; errored: boolean;
} }
interface MWNextData { interface MWNextData {
id: string; id: string;
eventId: string;
type: "embed" | "provider"; type: "embed" | "provider";
} }
export interface MWProviderRunContext { type MWProviderRunContextBase = {
media: DetailedMeta; media: DetailedMeta;
onProgress?: (data: MWProgressData) => void; onProgress?: (data: MWProgressData) => void;
onNext?: (data: MWNextData) => 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( async function findBestEmbedStream(
result: MWProviderScrapeResult, result: MWProviderScrapeResult,
providerId: string,
ctx: MWProviderRunContext ctx: MWProviderRunContext
): Promise<MWStream | null> { ): Promise<MWStream | null> {
if (result.stream) return result.stream; if (result.stream) return result.stream;
let embedNum = 0;
for (const embed of result.embeds) { for (const embed of result.embeds) {
embedNum += 1;
if (!embed.type) continue; if (!embed.type) continue;
const scraper = getEmbedScraperByType(embed.type); const scraper = getEmbedScraperByType(embed.type);
if (!scraper) throw new Error("Type for embed not found"); 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; let stream: MWStream;
try { try {
@ -41,6 +63,7 @@ async function findBestEmbedStream(
progress(num) { progress(num) {
ctx.onProgress?.({ ctx.onProgress?.({
errored: false, errored: false,
eventId,
id: scraper.id, id: scraper.id,
percentage: num, percentage: num,
type: "embed", type: "embed",
@ -50,6 +73,7 @@ async function findBestEmbedStream(
} catch { } catch {
ctx.onProgress?.({ ctx.onProgress?.({
errored: true, errored: true,
eventId,
id: scraper.id, id: scraper.id,
percentage: 100, percentage: 100,
type: "embed", type: "embed",
@ -59,6 +83,7 @@ async function findBestEmbedStream(
ctx.onProgress?.({ ctx.onProgress?.({
errored: false, errored: false,
eventId,
id: scraper.id, id: scraper.id,
percentage: 100, percentage: 100,
type: "embed", type: "embed",
@ -76,24 +101,48 @@ export async function findBestStream(
const providers = getProviders(); const providers = getProviders();
for (const provider of providers) { 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; let result: MWProviderScrapeResult;
try { try {
result = await runProvider(provider, { let context: MWProviderContext;
if (ctx.type === MWMediaType.SERIES) {
context = {
media: ctx.media, media: ctx.media,
type: ctx.type,
episode: ctx.episode,
season: ctx.season,
progress(num) { progress(num) {
ctx.onProgress?.({ ctx.onProgress?.({
percentage: num, percentage: num,
eventId,
errored: false, errored: false,
id: provider.id, id: provider.id,
type: "provider", 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) { } catch (err) {
ctx.onProgress?.({ ctx.onProgress?.({
percentage: 100, percentage: 100,
errored: true, errored: true,
eventId,
id: provider.id, id: provider.id,
type: "provider", type: "provider",
}); });
@ -103,11 +152,12 @@ export async function findBestStream(
ctx.onProgress?.({ ctx.onProgress?.({
errored: false, errored: false,
id: provider.id, id: provider.id,
eventId,
percentage: 100, percentage: 100,
type: "provider", type: "provider",
}); });
const stream = await findBestEmbedStream(result, ctx); const stream = await findBestEmbedStream(result, provider.id, ctx);
if (!stream) continue; if (!stream) continue;
return stream; return stream;
} }

View File

@ -2,13 +2,10 @@ import { initializeScraperStore } from "./helpers/register";
// TODO backend system: // TODO backend system:
// - caption support // - caption support
// - hooks to run all providers one by one
// - move over old providers to new system // - move over old providers to new system
// - implement jons providers/embedscrapers // - implement jons providers/embedscrapers
// - show/episode support
// providers // providers
// -- nothing here yet
import "./providers/gdriveplayer"; import "./providers/gdriveplayer";
// embeds // embeds

View File

@ -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"; import { MWMediaMeta, MWMediaType } from "./types";
const JW_API_BASE = "https://apis.justwatch.com";
// http://localhost:5173/#/media/movie-439596/
type JWExternalIdType = type JWExternalIdType =
| "eidr" | "eidr"
| "imdb_latest" | "imdb_latest"
@ -31,18 +34,23 @@ export interface DetailedMeta {
export async function getMetaFromId( export async function getMetaFromId(
type: MWMediaType, type: MWMediaType,
id: string id: string
): Promise<DetailedMeta> { ): Promise<DetailedMeta | null> {
let queryType = ""; const queryType = mediaTypeToJW(type);
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");
const data = await fetch( let data: JWDetailedMeta;
`${JW_API_BASE}/content/titles/${queryType}/${encodeURIComponent( try {
id const url = makeUrl("/content/titles/{type}/{id}/locale/en_US", {
)}/locale/en_US` type: queryType,
).then((res) => res.json() as Promise<JWDetailedMeta>); 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( const imdbId = data.external_ids.find(
(v) => v.provider === "imdb_latest" (v) => v.provider === "imdb_latest"

View File

@ -1,6 +1,7 @@
import { MWMediaType } from "./types"; import { MWMediaType } from "./types";
export const JW_API_BASE = "https://apis.justwatch.com"; 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 JWContentTypes = "movie" | "show";
@ -32,10 +33,7 @@ export function formatJWMeta(media: JWMediaResult) {
id: media.id.toString(), id: media.id.toString(),
year: media.original_release_year.toString(), year: media.original_release_year.toString(),
poster: media.poster poster: media.poster
? `https://images.justwatch.com${media.poster.replace( ? `${JW_IMAGE_BASE}${media.poster.replace("{profile}", "s166")}`
"{profile}",
"s166"
)}`
: undefined, : undefined,
type, type,
}; };

View File

@ -1,10 +1,19 @@
import { SimpleCache } from "@/utils/cache";
import { mwFetch } from "../helpers/fetch";
import { import {
formatJWMeta, formatJWMeta,
JWContentTypes, JWContentTypes,
JWMediaResult, JWMediaResult,
JW_API_BASE, JW_API_BASE,
mediaTypeToJW,
} from "./justwatch"; } 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 = { type JWSearchQuery = {
content_types: JWContentTypes[]; content_types: JWContentTypes[];
@ -21,26 +30,29 @@ type JWPage<T> = {
total_results: number; total_results: number;
}; };
export async function searchForMedia({ export async function searchForMedia(query: MWQuery): Promise<MWMediaMeta[]> {
searchQuery, if (cache.has(query)) return cache.get(query) as MWMediaMeta[];
type, const { searchQuery, type } = query;
}: MWQuery): Promise<MWMediaMeta[]> {
const contentType = mediaTypeToJW(type);
const body: JWSearchQuery = { const body: JWSearchQuery = {
content_types: [], content_types: [contentType],
page: 1, page: 1,
query: searchQuery, query: searchQuery,
page_size: 40, 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( const data = await mwFetch<JWPage<JWMediaResult>>(
`${JW_API_BASE}/content/titles/en_US/popular?body=${encodeURIComponent( "/content/titles/en_US/popular",
JSON.stringify(body) {
)}` baseURL: JW_API_BASE,
).then((res) => res.json() as Promise<JWPage<JWMediaResult>>); 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;
} }

View File

@ -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 { registerProvider } from "@/backend/helpers/register";
import { MWMediaType } from "@/backend/metadata/types"; import { MWMediaType } from "@/backend/metadata/types";
import { MWStreamQuality } from "@/backend/helpers/streams"; import { MWStreamQuality } from "@/backend/helpers/streams";
import { proxiedFetch } from "../helpers/fetch";
import { unpack } from "unpacker";
import CryptoJS from "crypto-js";
const format = { const format = {
stringify: (cipher: any) => { stringify: (cipher: any) => {
@ -34,16 +34,20 @@ const format = {
registerProvider({ registerProvider({
id: "gdriveplayer", id: "gdriveplayer",
displayName: "gdriveplayer",
rank: 69, rank: 69,
type: [MWMediaType.MOVIE], type: [MWMediaType.MOVIE],
async scrape({ progress, media: { imdbId } }) { async scrape({ progress, media: { imdbId } }) {
progress(10); progress(10);
const streamRes = await fetch( const streamRes = await proxiedFetch<string>(
`${ "https://database.gdriveplayer.us/player.php",
conf().CORS_PROXY_URL {
}https://database.gdriveplayer.us/player.php?imdb=${imdbId}` params: {
).then((d) => d.text()); imdb: imdbId,
},
}
);
progress(90); progress(90);
const page = new DOMParser().parseFromString(streamRes, "text/html"); const page = new DOMParser().parseFromString(streamRes, "text/html");
@ -67,6 +71,7 @@ registerProvider({
{ format } { format }
).toString(CryptoJS.enc.Utf8) ).toString(CryptoJS.enc.Utf8)
); );
// eslint-disable-next-line // eslint-disable-next-line
const sources = JSON.parse( const sources = JSON.parse(
JSON.stringify( JSON.stringify(

View File

@ -5,6 +5,62 @@ import { Link } from "@/components/text/Link";
import { Title } from "@/components/text/Title"; import { Title } from "@/components/text/Title";
import { conf } from "@/setup/config"; 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&apos;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 { interface ErrorBoundaryState {
hasError: boolean; hasError: boolean;
error?: { error?: {
@ -50,33 +106,6 @@ export class ErrorBoundary extends Component<
render() { render() {
if (!this.state.hasError) return this.props.children as any; if (!this.state.hasError) return this.props.children as any;
return ( return <ErrorMessage error={this.state.error} />;
<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&apos;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>
);
} }
} }

View File

@ -7,7 +7,7 @@ interface VideoPlayerHeaderProps {
} }
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) { export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
const showDivider = props.title || props.onClick; const showDivider = props.title && props.onClick;
return ( return (
<div className="flex items-center"> <div className="flex items-center">
<div className="flex flex-1 items-center"> <div className="flex flex-1 items-center">

12
src/hooks/useGoBack.ts Normal file
View 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;
}

View File

@ -1,16 +1,30 @@
import { findBestStream } from "@/backend/helpers/scrape"; import { findBestStream } from "@/backend/helpers/scrape";
import { MWStream } from "@/backend/helpers/streams"; import { MWStream } from "@/backend/helpers/streams";
import { DetailedMeta } from "@/backend/metadata/getmeta"; import { DetailedMeta } from "@/backend/metadata/getmeta";
import { MWMediaType } from "@/backend/metadata/types";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export interface ScrapeEventLog { export interface ScrapeEventLog {
type: "provider" | "embed"; type: "provider" | "embed";
errored: boolean; errored: boolean;
percentage: number; percentage: number;
eventId: string;
id: 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 [eventLog, setEventLog] = useState<ScrapeEventLog[]>([]);
const [stream, setStream] = useState<MWStream | null>(null); const [stream, setStream] = useState<MWStream | null>(null);
const [pending, setPending] = useState(true); const [pending, setPending] = useState(true);
@ -22,12 +36,14 @@ export function useScrape(meta: DetailedMeta) {
(async () => { (async () => {
const scrapedStream = await findBestStream({ const scrapedStream = await findBestStream({
media: meta, media: meta,
...selected,
onNext(ctx) { onNext(ctx) {
setEventLog((arr) => [ setEventLog((arr) => [
...arr, ...arr,
{ {
errored: false, errored: false,
id: ctx.id, id: ctx.id,
eventId: ctx.eventId,
type: ctx.type, type: ctx.type,
percentage: 0, percentage: 0,
}, },
@ -48,7 +64,7 @@ export function useScrape(meta: DetailedMeta) {
setPending(false); setPending(false);
setStream(scrapedStream); setStream(scrapedStream);
})(); })();
}, [meta]); }, [meta, selected]);
return { return {
stream, stream,

View File

@ -33,6 +33,9 @@ if (key) {
// - devices: ipadOS // - devices: ipadOS
// - features: HLS, error handling, preload interactions // - features: HLS, error handling, preload interactions
// TODO general todos:
// - localize everything
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <React.StrictMode>
<ErrorBoundary> <ErrorBoundary>

View 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>
);
}

View File

@ -71,7 +71,7 @@ export function MediaScrapeLog(props: MediaScrapeLogProps) {
> >
<MediaScrapePillSkeleton /> <MediaScrapePillSkeleton />
{props.events.map((v) => ( {props.events.map((v) => (
<MediaScrapePill event={v} key={v.id} /> <MediaScrapePill event={v} key={v.eventId} />
))} ))}
<MediaScrapePillSkeleton /> <MediaScrapePillSkeleton />
</div> </div>

View File

@ -1,14 +1,21 @@
import { useHistory, useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useCallback, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { DecoratedVideoPlayer } from "@/components/video/DecoratedVideoPlayer"; import { DecoratedVideoPlayer } from "@/components/video/DecoratedVideoPlayer";
import { MWStream } from "@/backend/helpers/streams"; 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 { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta"; import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
import { JWMediaToMediaType } from "@/backend/metadata/justwatch"; import { JWMediaToMediaType } from "@/backend/metadata/justwatch";
import { SourceControl } from "@/components/video/controls/SourceControl"; import { SourceControl } from "@/components/video/controls/SourceControl";
import { Loading } from "@/components/layout/Loading"; 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 { MediaScrapeLog } from "./MediaScrapeLog";
import { NotFoundMedia, NotFoundWrapper } from "../notfound/NotFoundView";
function MediaViewLoading(props: { onGoBack(): void }) { function MediaViewLoading(props: { onGoBack(): void }) {
return ( return (
@ -28,9 +35,10 @@ interface MediaViewScrapingProps {
onStream(stream: MWStream): void; onStream(stream: MWStream): void;
onGoBack(): void; onGoBack(): void;
meta: DetailedMeta; meta: DetailedMeta;
selected: SelectedMediaData;
} }
function MediaViewScraping(props: MediaViewScrapingProps) { function MediaViewScraping(props: MediaViewScrapingProps) {
const { eventLog, stream } = useScrape(props.meta); const { eventLog, stream, pending } = useScrape(props.meta, props.selected);
useEffect(() => { useEffect(() => {
if (stream) { if (stream) {
@ -38,8 +46,6 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
} }
}, [stream, props]); }, [stream, props]);
// TODO error screen if no streams found
return ( return (
<div className="relative flex h-screen items-center justify-center"> <div className="relative flex h-screen items-center justify-center">
<div className="absolute inset-x-0 top-0 py-6 px-8"> <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} title={props.meta.meta.title}
/> />
</div> </div>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center transition-opacity duration-200">
<Loading className="mb-4" /> {pending ? (
<p className="mb-8 text-denim-700">Finding the best video for you</p> <>
<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&apos;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} /> <MediaScrapeLog events={eventLog} />
</div> </div>
</div> </div>
</div>
); );
} }
export function MediaView() { export function MediaView() {
const reactHistory = useHistory();
const params = useParams<{ media: string }>(); const params = useParams<{ media: string }>();
const goBack = useCallback(() => { const goBack = useGoBack();
if (reactHistory.action !== "POP") reactHistory.goBack();
else reactHistory.push("/");
}, [reactHistory]);
const [meta, setMeta] = useState<DetailedMeta | null>(null); 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); const [stream, setStream] = useState<MWStream | null>(null);
useEffect(() => { useEffect(() => {
// TODO handle errors exec(params.media).then((v) => {
(async () => { setMeta(v ?? null);
const [t, id] = params.media.split("-", 2); if (v)
const type = JWMediaToMediaType(t); setSelected({
const fetchedMeta = await getMetaFromId(type, id); type: v.meta.type,
setMeta(fetchedMeta); episode: 0 as any,
})(); season: 0 as any,
}, [setMeta, params]); });
else setSelected(null);
});
}, [exec, params.media]);
// TODO watched store // TODO watched store
// TODO error page with video header // 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) if (!stream)
return ( 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 ( return (
<div className="h-screen w-screen"> <div className="h-screen w-screen">
<DecoratedVideoPlayer title={meta.meta.title} onGoBack={goBack} autoPlay> <DecoratedVideoPlayer title={meta.meta.title} onGoBack={goBack} autoPlay>

View File

@ -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;
}

View File

@ -5,11 +5,24 @@ import { Icons } from "@/components/Icon";
import { Navigation } from "@/components/layout/Navigation"; import { Navigation } from "@/components/layout/Navigation";
import { ArrowLink } from "@/components/text/ArrowLink"; import { ArrowLink } from "@/components/text/ArrowLink";
import { Title } from "@/components/text/Title"; 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 ( return (
<div className="h-screen flex-1"> <div className="h-screen flex-1">
{props.video ? (
<div className="fixed inset-x-0 top-0 py-6 px-8">
<VideoPlayerHeader onClick={goBack} />
</div>
) : (
<Navigation /> <Navigation />
)}
<div className="flex h-full flex-col items-center justify-center p-5 text-center"> <div className="flex h-full flex-col items-center justify-center p-5 text-center">
{props.children} {props.children}
</div> </div>

View File

@ -52,7 +52,6 @@ export function SearchResultsView({ searchQuery }: { searchQuery: MWQuery }) {
); );
useEffect(() => { useEffect(() => {
// TODO use cache
async function runSearch(query: MWQuery) { async function runSearch(query: MWQuery) {
const searchResults = await runSearchQuery(query); const searchResults = await runSearchQuery(query);
if (!searchResults) return; if (!searchResults) return;

View File

@ -974,6 +974,11 @@
"depd@^1.1.2": "depd@^1.1.2":
"version" "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": "detective@^5.2.1":
"integrity" "sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==" "integrity" "sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw=="
"resolved" "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz" "resolved" "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz"
@ -2363,6 +2368,11 @@
"negotiator@^0.6.3": "negotiator@^0.6.3":
"version" "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": "node-fetch@2.6.7":
"integrity" "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==" "integrity" "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="
"resolved" "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz" "resolved" "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz"
@ -2631,6 +2641,15 @@
"define-properties" "^1.1.4" "define-properties" "^1.1.4"
"es-abstract" "^1.20.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": "once@^1.3.0":
"integrity" "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==" "integrity" "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="
"resolved" "https://registry.npmjs.org/once/-/once-1.4.0.tgz" "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" "resolved" "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz"
"version" "4.9.4" "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": "unbox-primitive@^1.0.2":
"integrity" "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==" "integrity" "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw=="
"resolved" "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz" "resolved" "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz"