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" "close": "Close"
}, },
"player": { "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": { "back": {
"default": "Back to home", "default": "Back to home",
"short": "Back" "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.", "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" "title": "Failed to load metadata"
}, },
"api": {
"text": "Could not load API metadata, please check your internet connection.",
"title": "Failed to load API metadata"
},
"notFound": { "notFound": {
"badge": "Not found", "badge": "Not found",
"homeButton": "Back to home", "homeButton": "Back to home",

View File

@ -2,12 +2,14 @@ import classNames from "classnames";
import FocusTrap from "focus-trap-react"; import FocusTrap from "focus-trap-react";
import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Transition } from "@/components/utils/Transition"; import { Transition } from "@/components/utils/Transition";
import { import {
useInternalOverlayRouter, useInternalOverlayRouter,
useRouterAnchorUpdate, useRouterAnchorUpdate,
} from "@/hooks/useOverlayRouter"; } from "@/hooks/useOverlayRouter";
import { TurnstileProvider } from "@/stores/turnstile";
export interface OverlayProps { export interface OverlayProps {
id: string; id: string;
@ -15,6 +17,34 @@ export interface OverlayProps {
darken?: boolean; 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 }) { export function OverlayDisplay(props: { children: ReactNode }) {
const router = useInternalOverlayRouter("hello world :)"); const router = useInternalOverlayRouter("hello world :)");
const refRouter = useRef(router); const refRouter = useRef(router);
@ -27,7 +57,12 @@ export function OverlayDisplay(props: { children: ReactNode }) {
r.close(); r.close();
}; };
}, []); }, []);
return <div className="popout-location">{props.children}</div>; return (
<div className="popout-location">
<TurnstileInteractive />
{props.children}
</div>
);
} }
export function OverlayPortal(props: { export function OverlayPortal(props: {

View File

@ -43,7 +43,11 @@ export function MetaPart(props: MetaPartProps) {
const { error, value, loading } = useAsync(async () => { const { error, value, loading } = useAsync(async () => {
const providerApiUrl = getLoadbalancedProviderApiUrl(); const providerApiUrl = getLoadbalancedProviderApiUrl();
if (providerApiUrl) { if (providerApiUrl) {
await fetchMetadata(providerApiUrl); try {
await fetchMetadata(providerApiUrl);
} catch (err) {
throw new Error("failed-api-metadata");
}
} else { } else {
setCachedMetadata([ setCachedMetadata([
...providers.listSources(), ...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) { if (error) {
return ( return (
<ErrorLayout> <ErrorLayout>

View File

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

View File

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

View File

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