Handle more turnstile errors + show interactive prompt + handle provider api metadata errors

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
mrjvs 2024-01-03 23:54:08 +01:00
parent 0fec65ea7b
commit 1091253392
6 changed files with 178 additions and 28 deletions

View file

@ -165,6 +165,12 @@
"close": "Close"
},
"player": {
"turnstile": {
"verifyingHumanity": "Verifying your humanity...",
"title": "We need to verify that you're human.",
"description": "Please verify that you are human by completing the Captcha on the right. This is to keep movie-web safe!",
"error": "Failed to verify your humanity. Please try again."
},
"back": {
"default": "Back to home",
"short": "Back"
@ -261,6 +267,10 @@
"text": "Could not load the media's metadata from TMDB. Please check whether TMDB is down or blocked on your internet connection.",
"title": "Failed to load metadata"
},
"api": {
"text": "Could not load API metadata, please check your internet connection.",
"title": "Failed to load API metadata"
},
"notFound": {
"badge": "Not found",
"homeButton": "Back to home",

View file

@ -2,12 +2,14 @@ import classNames from "classnames";
import FocusTrap from "focus-trap-react";
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Transition } from "@/components/utils/Transition";
import {
useInternalOverlayRouter,
useRouterAnchorUpdate,
} from "@/hooks/useOverlayRouter";
import { TurnstileProvider } from "@/stores/turnstile";
export interface OverlayProps {
id: string;
@ -15,6 +17,34 @@ export interface OverlayProps {
darken?: boolean;
}
function TurnstileInteractive() {
const { t } = useTranslation();
const [show, setShow] = useState(false);
// this may not rerender with different dom structure, must be exactly the same always
return (
<div
className={classNames(
"absolute w-10/12 max-w-[800px] bg-background-main p-20 rounded-lg select-none z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform",
show ? "" : "hidden",
)}
>
<div className="w-full grid lg:grid-cols-[1fr,auto] gap-12 items-center">
<div className="text-left">
<h2 className="text-type-emphasis font-bold text-xl mb-6">
{t("player.turnstile.title")}
</h2>
<p>{t("player.turnstile.description")}</p>
</div>
<TurnstileProvider
isInPopout
onUpdateShow={(shouldShow) => setShow(shouldShow)}
/>
</div>
</div>
);
}
export function OverlayDisplay(props: { children: ReactNode }) {
const router = useInternalOverlayRouter("hello world :)");
const refRouter = useRef(router);
@ -27,7 +57,12 @@ export function OverlayDisplay(props: { children: ReactNode }) {
r.close();
};
}, []);
return <div className="popout-location">{props.children}</div>;
return (
<div className="popout-location">
<TurnstileInteractive />
{props.children}
</div>
);
}
export function OverlayPortal(props: {

View file

@ -43,7 +43,11 @@ export function MetaPart(props: MetaPartProps) {
const { error, value, loading } = useAsync(async () => {
const providerApiUrl = getLoadbalancedProviderApiUrl();
if (providerApiUrl) {
await fetchMetadata(providerApiUrl);
try {
await fetchMetadata(providerApiUrl);
} catch (err) {
throw new Error("failed-api-metadata");
}
} else {
setCachedMetadata([
...providers.listSources(),
@ -117,6 +121,28 @@ export function MetaPart(props: MetaPartProps) {
);
}
if (error && error.message === "failed-api-metadata") {
return (
<ErrorLayout>
<ErrorContainer>
<IconPill icon={Icons.WAND}>
{t("player.metadata.failed.badge")}
</IconPill>
<Title>{t("player.metadata.api.text")}</Title>
<Paragraph>{t("player.metadata.api.title")}</Paragraph>
<Button
href="/"
theme="purple"
padding="md:px-12 p-2.5"
className="mt-6"
>
{t("player.metadata.failed.homeButton")}
</Button>
</ErrorContainer>
</ErrorLayout>
);
}
if (error) {
return (
<ErrorLayout>

View file

@ -1,6 +1,7 @@
import { ProviderControls, ScrapeMedia } from "@movie-web/providers";
import classNames from "classnames";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMountedState } from "react-use";
import type { AsyncReturnType } from "type-fest";
@ -8,6 +9,8 @@ import {
scrapePartsToProviderMetric,
useReportProviders,
} from "@/backend/helpers/report";
import { Icon, Icons } from "@/components/Icon";
import { Loading } from "@/components/layout/Loading";
import {
ScrapeCard,
ScrapeItem,
@ -18,6 +21,7 @@ import {
useListCenter,
useScrape,
} from "@/hooks/useProviderScrape";
import { LargeTextPart } from "@/pages/parts/util/LargeTextPart";
export interface ScrapingProps {
media: ScrapeMedia;
@ -32,9 +36,11 @@ export function ScrapingPart(props: ScrapingProps) {
const { report } = useReportProviders();
const { startScraping, sourceOrder, sources, currentSource } = useScrape();
const isMounted = useMountedState();
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const [failedStartScrape, setFailedStartScrape] = useState<boolean>(false);
const renderedOnce = useListCenter(
containerRef,
listRef,
@ -72,7 +78,7 @@ export function ScrapingPart(props: ScrapingProps) {
),
);
props.onGetStream?.(output);
})();
})().catch(() => setFailedStartScrape(true));
}, [startScraping, props, report, isMounted]);
let currentProviderIndex = sourceOrder.findIndex(
@ -81,11 +87,28 @@ export function ScrapingPart(props: ScrapingProps) {
if (currentProviderIndex === -1)
currentProviderIndex = sourceOrder.length - 1;
if (failedStartScrape)
return (
<LargeTextPart
iconSlot={
<Icon className="text-type-danger text-2xl" icon={Icons.WARNING} />
}
>
{t("player.turnstile.error")}
</LargeTextPart>
);
return (
<div
className="h-full w-full relative dir-neutral:origin-top-left flex"
ref={containerRef}
>
{!sourceOrder || sourceOrder.length === 0 ? (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center flex flex-col justify-center z-0">
<Loading className="mb-8" />
<p>{t("player.turnstile.verifyingHumanity")}</p>
</div>
) : null}
<div
className={classNames({
"absolute transition-[transform,opacity] opacity-0 dir-neutral:left-0":
@ -97,7 +120,7 @@ export function ScrapingPart(props: ScrapingProps) {
{sourceOrder.map((order) => {
const source = sources[order.id];
const distance = Math.abs(
sourceOrder.findIndex((t) => t.id === order.id) -
sourceOrder.findIndex((o) => o.id === order.id) -
currentProviderIndex,
);
return (

View file

@ -11,24 +11,32 @@ interface BannerInstance {
interface BannerStore {
banners: BannerInstance[];
isOnline: boolean;
isTurnstile: boolean;
location: string | null;
updateHeight(id: string, height: number): void;
showBanner(id: string): void;
hideBanner(id: string): void;
setLocation(loc: string | null): void;
updateOnline(isOnline: boolean): void;
updateTurnstile(isTurnstile: boolean): void;
}
export const useBannerStore = create(
immer<BannerStore>((set) => ({
banners: [],
isOnline: true,
isTurnstile: false,
location: null,
updateOnline(isOnline) {
set((s) => {
s.isOnline = isOnline;
});
},
updateTurnstile(isTurnstile) {
set((s) => {
s.isTurnstile = isTurnstile;
});
},
setLocation(loc) {
set((s) => {
s.location = loc;

View file

@ -1,3 +1,5 @@
import classNames from "classnames";
import { useRef } from "react";
import Turnstile, { BoundTurnstileObject } from "react-turnstile";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
@ -6,19 +8,31 @@ import { reportCaptchaSolve } from "@/backend/helpers/report";
import { conf } from "@/setup/config";
export interface TurnstileStore {
turnstile: BoundTurnstileObject | null;
isInWidget: boolean;
turnstiles: {
controls: BoundTurnstileObject;
isInPopout: boolean;
id: string;
}[];
cbs: ((token: string | null) => void)[];
setTurnstile(v: BoundTurnstileObject | null): void;
setTurnstile(
id: string,
v: BoundTurnstileObject | null,
isInPopout: boolean,
): void;
getToken(): Promise<string>;
processToken(token: string | null): void;
processToken(token: string | null, widgetId: string): void;
}
export const useTurnstileStore = create(
immer<TurnstileStore>((set, get) => ({
turnstile: null,
isInWidget: false,
turnstiles: [],
cbs: [],
processToken(token) {
processToken(token, widgetId) {
const cbs = get().cbs;
const turnstile = get().turnstiles.find((v) => v.id === widgetId);
if (turnstile?.id !== widgetId) return;
cbs.forEach((fn) => fn(token));
set((s) => {
s.cbs = [];
@ -37,16 +51,26 @@ export const useTurnstileStore = create(
});
});
},
setTurnstile(v) {
setTurnstile(id, controls, isInPopout) {
set((s) => {
s.turnstile = v;
s.turnstiles = s.turnstiles.filter((v) => v.id !== id);
if (controls) {
s.turnstiles.push({
controls,
isInPopout,
id,
});
}
});
},
})),
);
export function getTurnstile() {
return useTurnstileStore.getState().turnstile;
const turnstiles = useTurnstileStore.getState().turnstiles;
const inPopout = turnstiles.find((v) => v.isInPopout);
if (inPopout) return inPopout;
return turnstiles[0];
}
export function isTurnstileInitialized() {
@ -55,9 +79,12 @@ export function isTurnstileInitialized() {
export async function getTurnstileToken() {
const turnstile = getTurnstile();
turnstile?.reset();
turnstile?.execute();
try {
// I hate turnstile
(window as any).turnstile.execute(
document.querySelector(`#${turnstile.id}`),
{},
);
const token = await useTurnstileStore.getState().getToken();
reportCaptchaSolve(true);
return token;
@ -67,23 +94,44 @@ export async function getTurnstileToken() {
}
}
export function TurnstileProvider() {
export function TurnstileProvider(props: {
isInPopout?: boolean;
onUpdateShow?: (show: boolean) => void;
}) {
const siteKey = conf().TURNSTILE_KEY;
const idRef = useRef<string | null>(null);
const setTurnstile = useTurnstileStore((s) => s.setTurnstile);
const processToken = useTurnstileStore((s) => s.processToken);
if (!siteKey) return null;
return (
<Turnstile
sitekey={siteKey}
onLoad={(_widgetId, bound) => {
setTurnstile(bound);
}}
onError={() => {
processToken(null);
}}
onVerify={(token) => {
processToken(token);
}}
/>
<div
className={classNames({
hidden: !props.isInPopout,
})}
>
<Turnstile
sitekey={siteKey}
onLoad={(widgetId, bound) => {
idRef.current = widgetId;
setTurnstile(widgetId, bound, !!props.isInPopout);
}}
onError={() => {
const id = idRef.current;
if (!id) return;
processToken(null, id);
}}
onVerify={(token) => {
const id = idRef.current;
if (!id) return;
processToken(token, id);
props.onUpdateShow?.(false);
}}
onBeforeInteractive={() => {
props.onUpdateShow?.(true);
}}
refreshExpired="never"
execution="render"
/>
</div>
);
}